Skip to content
Closed
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
4 changes: 2 additions & 2 deletions src/XTerm.NET.Tests/VtTestConformanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ public void Eight_bit_controls_change_the_reply_prefix()
/// vttest's single-shift test passes without this working, because it runs with G2 and G3 as
/// ISO Latin-1 whose mapping is the identity. See tomlm/XTerm.NET#131.
/// </remarks>
[Fact(Skip = "Unfixed: SS2/SS3/LS2/LS3 are not dispatched, so G2 and G3 are unreachable. tomlm/XTerm.NET#131")]
[Fact]
public void A_locking_shift_reaches_G2()
{
var (terminal, _) = Listening();
Expand Down Expand Up @@ -256,7 +256,7 @@ public void G0_reaches_the_special_graphics_set()
/// <remarks>
/// The primary DA advertises feature 9 while only UK is implemented. See tomlm/XTerm.NET#132.
/// </remarks>
[Fact(Skip = "Unfixed: only DEC graphics, UK and ASCII exist; the rest fall back to ASCII. tomlm/XTerm.NET#132")]
[Fact]
public void A_national_replacement_set_remaps_its_positions()
{
var (terminal, _) = Listening();
Expand Down
160 changes: 153 additions & 7 deletions src/XTerm.NET/Common/Charsets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,143 @@ public static class Charsets
{ '#', "\u00a3" } // £
};

/// <summary>
/// The DEC national replacement character sets (NRCS), by their designator.
/// </summary>
/// <remarks>
/// <para>Each remaps a handful of positions in the ASCII range and leaves the rest alone --
/// which is what makes them REPLACEMENT sets rather than alphabets, and why a missing one is
/// invisible: the text comes out almost right, with the accented positions quietly reading as
/// # @ [ ] { } instead.</para>
///
/// <para>They apply only while DECNRCM (mode 42) is set. With it reset the designation is
/// still remembered, but the set behaves as ASCII -- which is what the mode is for.</para>
/// </remarks>
public static readonly Dictionary<string, Dictionary<char, string>> National = new()
{
// French
["R"] = new()
{
{ '#', "\u00a3" },
{ '@', "\u00e0" },
{ '[', "\u00b0" },
{ '\\', "\u00e7" },
{ ']', "\u00a7" },
{ '{', "\u00e9" },
{ '|', "\u00f9" },
{ '}', "\u00e8" },
{ '~', "\u00a8" },
},
// German
["K"] = new()
{
{ '@', "\u00a7" },
{ '[', "\u00c4" },
{ '\\', "\u00d6" },
{ ']', "\u00dc" },
{ '{', "\u00e4" },
{ '|', "\u00f6" },
{ '}', "\u00fc" },
{ '~', "\u00df" },
},
// Swedish
["H"] = new()
{
{ '@', "\u00c9" },
{ '[', "\u00c4" },
{ '\\', "\u00d6" },
{ ']', "\u00c5" },
{ '^', "\u00dc" },
{ '`', "\u00e9" },
{ '{', "\u00e4" },
{ '|', "\u00f6" },
{ '}', "\u00e5" },
{ '~', "\u00fc" },
},
// Italian
["Y"] = new()
{
{ '#', "\u00a3" },
{ '@', "\u00a7" },
{ '[', "\u00b0" },
{ '\\', "\u00e7" },
{ ']', "\u00e9" },
{ '`', "\u00f9" },
{ '{', "\u00e0" },
{ '|', "\u00f2" },
{ '}', "\u00e8" },
{ '~', "\u00ec" },
},
// Spanish
["Z"] = new()
{
{ '#', "\u00a3" },
{ '@', "\u00a7" },
{ '[', "\u00a1" },
{ '\\', "\u00d1" },
{ ']', "\u00bf" },
{ '{', "\u00b0" },
{ '|', "\u00f1" },
{ '}', "\u00e7" },
},
// Norwegian/Danish
["E"] = new()
{
{ '@', "\u00c4" },
{ '[', "\u00c6" },
{ '\\', "\u00d8" },
{ ']', "\u00c5" },
{ '^', "\u00dc" },
{ '`', "\u00e4" },
{ '{', "\u00e6" },
{ '|', "\u00f8" },
{ '}', "\u00e5" },
{ '~', "\u00fc" },
},
// Dutch
["4"] = new()
{
{ '#', "\u00a3" },
{ '@', "\u00be" },
{ '[', "\u0133" },
{ '\\', "\u00bd" },
{ ']', "\u007c" },
{ '{', "\u00a8" },
{ '|', "\u0066" },
{ '}', "\u00bc" },
{ '~', "\u00b4" },
},
// Swiss
["="] = new()
{
{ '#', "\u00f9" },
{ '@', "\u00e0" },
{ '[', "\u00e9" },
{ '\\', "\u00e7" },
{ ']', "\u00ea" },
{ '^', "\u00ee" },
{ '_', "\u00e8" },
{ '`', "\u00f4" },
{ '{', "\u00e4" },
{ '|', "\u00f6" },
{ '}', "\u00fc" },
{ '~', "\u00fb" },
},
// Finnish
["C"] = new()
{
{ '[', "\u00c4" },
{ '\\', "\u00d6" },
{ ']', "\u00c5" },
{ '^', "\u00dc" },
{ '`', "\u00e9" },
{ '{', "\u00e4" },
{ '|', "\u00f6" },
{ '}', "\u00e5" },
{ '~', "\u00fc" },
},
};

/// <summary>
/// ASCII character set (no translation).
/// Null dictionary means pass-through.
Expand All @@ -65,15 +202,24 @@ public static class Charsets
/// </summary>
/// <param name="name">Charset identifier: "0" (DEC Graphics), "A" (UK), "B" (US ASCII)</param>
/// <returns>Character translation dictionary, or null for pass-through</returns>
public static Dictionary<char, string>? GetCharset(string name)
public static Dictionary<char, string>? GetCharset(string name, bool nationalReplacement = false)
{
return name switch
switch (name)
{
"0" => VT100LineDrawing, // DEC Special Graphics
"A" => UKCharset, // UK
"B" => ASCII, // US ASCII (default)
_ => ASCII // Default to ASCII for unknown charsets
};
case "0": return VT100LineDrawing; // DEC Special Graphics
case "A": return UKCharset; // UK
case "B": return ASCII; // US ASCII (default)
}

// The national sets are gated on DECNRCM, so the same designation means different
// things depending on the mode -- which is why the caller re-resolves what it has
// designated when the mode changes rather than resolving once at designation time.
if (nationalReplacement && National.TryGetValue(name, out var national))
return national;

// Anything else is ASCII. That includes a national set while NRC is off, and it is
// the honest answer for a designation this terminal does not implement.
return ASCII;
}

/// <summary>
Expand Down
2 changes: 2 additions & 0 deletions src/XTerm.NET/InputHandler.Modes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ private void ResetCSIMode(int mode, bool isPrivate)

case TerminalMode.NationalCharset:
TrySetStoredMode(mode, isPrivate: true, value: false);
_terminal.NationalReplacementCharsets = false;
RefreshDesignatedCharsets();
break;

case TerminalMode.ReverseWraparound:
Expand Down
60 changes: 57 additions & 3 deletions src/XTerm.NET/InputHandler.Print.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,17 @@ public void Print(string data)
var translatedData = data;
if (data.Length == 1)
{
translatedData = Charsets.TranslateChar(data[0], _activeCharset);
// A single shift outranks GL for this character and is spent doing it.
if (_singleShiftPending)
{
translatedData = Charsets.TranslateChar(data[0], _singleShiftCharset);
_singleShiftCharset = null;
_singleShiftPending = false;
}
else
{
translatedData = Charsets.TranslateChar(data[0], _activeCharset);
}
}

var width = GetStringCellWidth(translatedData);
Expand Down Expand Up @@ -701,8 +711,21 @@ private bool TryPairRegionalIndicator(string data, int cellX)

private void SetCharset(CharsetMode mode, string charsetId)
{
var charset = Charsets.GetCharset(charsetId);
_charsets[mode] = charset;
// 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;
_charsets[mode] = Charsets.GetCharset(charsetId, _terminal.NationalReplacementCharsets);
RefreshActiveCharset();
}

/// <summary>Re-resolves every designation, for when DECNRCM changes under them.</summary>
internal void RefreshDesignatedCharsets()
{
foreach (var mode in _charsetIds.Keys.ToList())
_charsets[mode] = Charsets.GetCharset(_charsetIds[mode], _terminal.NationalReplacementCharsets);

RefreshActiveCharset();
}

Expand All @@ -721,14 +744,45 @@ public void ShiftOut()
public void ShiftIn()
{
_currentCharset = CharsetMode.G0;
_singleShiftCharset = null;
_singleShiftPending = false;
RefreshActiveCharset();
}

/// <summary>
/// LS2 (ESC n) and LS3 (ESC o) - lock G2 or G3 into GL until the next shift.
/// </summary>
/// <remarks>
/// SO and SI above are the same operation for G1 and G0. G2 and G3 could be DESIGNATED
/// before this existed and never invoked, so whatever a program put in them printed as
/// ASCII -- and silently, since an unimplemented set and US ASCII look identical.
/// </remarks>
public void LockingShift(CharsetMode mode)
{
_currentCharset = mode;
RefreshActiveCharset();
}

/// <summary>
/// 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.
/// </remarks>
public void InvokeSingleShift(CharsetMode mode)
{
_singleShiftCharset = _charsets.GetValueOrDefault(mode);
_singleShiftPending = true;
}

/// <summary>
/// Resets charset state to defaults.
/// </summary>
public void ResetCharsets()
{
_charsetIds.Clear();
_charsets[CharsetMode.G0] = Charsets.ASCII;
_charsets[CharsetMode.G1] = Charsets.ASCII;
_charsets[CharsetMode.G2] = Charsets.ASCII;
Expand Down
38 changes: 37 additions & 1 deletion src/XTerm.NET/InputHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,27 @@ public partial class InputHandler
/// through RefreshActiveCharset so the two cannot drift.
/// </summary>
private Dictionary<char, string>? _activeCharset;

/// <summary>What each G-set was DESIGNATED as, by its escape identifier.</summary>
/// <remarks>
/// 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.
/// </remarks>
private readonly Dictionary<CharsetMode, string> _charsetIds = new();

/// <summary>
/// The 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
/// 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.
/// </remarks>
private Dictionary<char, string>? _singleShiftCharset;

private bool _singleShiftPending;
private CharsetMode _currentCharset;

// Variation selector and combining character constants
Expand Down Expand Up @@ -152,6 +173,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
|| _buffer.HasMultiRowSizedRuns || !_terminal.Options.Wraparound)
{
foreach (var b in data)
Expand Down Expand Up @@ -259,6 +281,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
|| _buffer.HasMultiRowSizedRuns || !_terminal.Options.Wraparound)
{
for (var k = 0; k < count; k++)
Expand Down Expand Up @@ -875,6 +898,18 @@ public void HandleEsc(string finalChar, string collected)
case "7": // DECSC - Save Cursor
SaveCursor();
break;
case "N": // SS2 - single shift G2, for the next character only
InvokeSingleShift(CharsetMode.G2);
break;
case "O": // SS3 - single shift G3
InvokeSingleShift(CharsetMode.G3);
break;
case "n": // LS2 - lock G2 into GL until the next shift
LockingShift(CharsetMode.G2);
break;
case "o": // LS3 - lock G3 into GL
LockingShift(CharsetMode.G3);
break;
case "Z": // DECID - the ancient identify; answers like the primary DA
_terminal.RaiseDataReceived(PrimaryDeviceAttributes);
break;
Expand Down Expand Up @@ -1770,8 +1805,9 @@ private void SetCSIMode(int mode, bool isPrivate)
break;

case TerminalMode.NationalCharset:
// Stored so DECRQM can answer truthfully; nothing acts on it.
TrySetStoredMode(mode, isPrivate: true, value: true);
_terminal.NationalReplacementCharsets = true;
RefreshDesignatedCharsets();
break;

case TerminalMode.ReverseWraparound:
Expand Down
7 changes: 7 additions & 0 deletions src/XTerm.NET/Terminal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@ public void SetColumnMode(bool wide)
/// Mode 1034 (eightBitInput).
/// </summary>
public bool EightBitInput { get; set; }

/// <summary>DECNRCM (mode 42): whether national replacement sets are in force.</summary>
/// <remarks>
/// A designated national set behaves as ASCII while this is false, so changing it re-resolves
/// what is already designated rather than only affecting the next designation.
/// </remarks>
public bool NationalReplacementCharsets { get; set; }

/// <summary>
/// When enabled, pressing Meta+key sends ESC followed by the key.
Expand Down
Loading