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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*.user
*.userosscache
*.sln.docstates
*.idea

# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
Expand Down
9 changes: 6 additions & 3 deletions src/PrettyPrompt/Console/AnsiEscapeCodes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,12 @@ public static class AnsiEscapeCodes
// xterm "modifyOtherKeys" (XTMODKEYS, resource Pp=4). When enabled, combinations that otherwise
// share an encoding with an unmodified key - notably Shift/Ctrl/Alt+Enter, which all arrive as a
// bare CR - are reported as distinct "CSI 27 ; <modifier> ; <keycode> ~" sequences, which we parse
// in KeyPress.MapInputEscapeSequence. Level 2 is required: level 1 explicitly excludes the "well
// known" keys (Enter, Tab, Backspace, Escape). See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
public const string EnableModifyOtherKeys = $"{Escape}[>4;2m";
// in KeyPress.MapInputEscapeSequence. We use LEVEL 1, not 2: level 1 leaves keys with well-known
// behavior alone (Shift+letter still produces its capital via the keyboard layout), while level 2
// encodes *everything*, turning Shift+C into an escape sequence and breaking ordinary typing -
// and shifted punctuation can't be reconstructed without layout knowledge anyway.
// See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
public const string EnableModifyOtherKeys = $"{Escape}[>4;1m";
public const string DisableModifyOtherKeys = $"{Escape}[>4;0m";

/// <param name="index">Index starts at 1.</param>
Expand Down
51 changes: 40 additions & 11 deletions src/PrettyPrompt/Console/KeyPress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,12 @@ internal static IEnumerable<KeyPress> ReadForever(IConsole console)

/// <summary>
/// Parses an xterm "modifyOtherKeys" CSI 27 sequence of the form ESC [ 27 ; modifier ; keycode ~.
/// The modifier is encoded as 1 + a bitmask of Shift(1), Alt(2), Control(4). We only special-case the
/// Enter key (keycode 13) here - the combination that's otherwise lost on Unix/macOS; any other key is
/// left to .NET's normal handling. .NET's Unix console parser strips the leading '[' (compare the
/// function-key sequences above, which also lack it), so both forms are accepted.
/// The modifier is encoded as 1 + a bitmask of Shift(1), Alt(2), Control(4); the keycode is the Unicode
/// value of the base key. We enable level 1 (see <see cref="AnsiEscapeCodes.EnableModifyOtherKeys"/>), so we
/// only see combinations the terminal would otherwise have to encode ambiguously (Shift/Ctrl/Alt+Enter,
/// Ctrl/Alt+letter, ...). We decode all of them rather than just Enter, so none get dropped - since we asked
/// the terminal to emit these, anything we didn't translate would otherwise vanish. .NET's Unix console parser
/// strips the leading '[' (compare the function-key sequences above, which also lack it), so both are accepted.
/// </summary>
private static bool TryMapModifyOtherKeys(string sequence, [NotNullWhen(true)] out KeyPress? keyPress)
{
Expand All @@ -155,21 +157,48 @@ private static bool TryMapModifyOtherKeys(string sequence, [NotNullWhen(true)] o
var parts = body[..^1].Split(';'); // ["27", "<modifier>", "<keycode>"]
if (parts.Length != 3 ||
!int.TryParse(parts[1], out var modifier) ||
!int.TryParse(parts[2], out var keyCode) ||
keyCode != 13) // 13 == '\r'; only Enter is ambiguous enough to need this.
!int.TryParse(parts[2], out var keyCode))
{
return false;
}

var modifierMask = modifier - 1;
keyPress = new KeyPress(ConsoleKey.Enter.ToKeyInfo(
'\r', // the KeyPress constructor normalizes this to '\n' while preserving the modifiers.
shift: (modifierMask & 1) != 0,
alt: (modifierMask & 2) != 0,
control: (modifierMask & 4) != 0));
var shift = (modifierMask & 1) != 0;
var alt = (modifierMask & 2) != 0;
var control = (modifierMask & 4) != 0;

var (key, keyChar) = MapModifyOtherKeysCode(keyCode, shift, control);
// For Enter, the KeyPress constructor normalizes the '\r' KeyChar to '\n' while preserving the modifiers.
keyPress = new KeyPress(new ConsoleKeyInfo(keyChar, key, shift, alt, control));
return true;
}

/// <summary>
/// Maps a modifyOtherKeys keycode (the Unicode value of the base key) to a <see cref="ConsoleKey"/> and the
/// glyph to insert. Bindings driven by these (Ctrl/Alt+letter, Enter, ...) match on Key+Modifiers, so the
/// glyph only matters for keys that actually produce text. Because we enable level 1, plain Shift+printable
/// is left to the keyboard layout and won't arrive here - so we never reconstruct shifted punctuation (which
/// we couldn't without layout knowledge); letters are upper-cased for the bare-Shift case just in case.
/// </summary>
private static (ConsoleKey Key, char KeyChar) MapModifyOtherKeysCode(int code, bool shift, bool control)
{
switch (code)
{
case 13: return (ConsoleKey.Enter, '\r');
case 9: return (ConsoleKey.Tab, '\t');
case 27: return (ConsoleKey.Escape, (char)27);
case 8 or 127: return (ConsoleKey.Backspace, '\b');
case 32: return (ConsoleKey.Spacebar, ' ');
}

// Control combinations are matched on Key+Modifiers, so their glyph is irrelevant - use '\0'. Otherwise
// pass the character through, upper-casing a bare-shifted letter so it still types as a capital.
if (code is >= 'a' and <= 'z') return (ConsoleKey.A + (code - 'a'), control ? '\0' : (char)(shift ? code - 32 : code));
if (code is >= 'A' and <= 'Z') return (ConsoleKey.A + (code - 'A'), control ? '\0' : (char)code);
if (code is >= '0' and <= '9') return ((ConsoleKey)code, control ? '\0' : (char)code); // ConsoleKey.D0..D9 == '0'..'9'
return (default, control ? '\0' : (char)code);
}

/// <summary>
/// Read any remaining key presses in the buffer, including the provided <paramref name="key"/>.
/// </summary>
Expand Down
6 changes: 5 additions & 1 deletion src/PrettyPrompt/Documents/WordWrapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,11 @@ public static WordWrappedText WrapEditableCharacters(ReadOnlyStringBuilder input
}
}

if (currentLineLength > 0 || input[^1] == '\n')
// Flush on `line.Length`, not `currentLineLength`: a trailing line consisting only of zero-width
// characters (e.g. a lone combining mark such as the Thai tone mark U+0E49) has zero display width but
// real content that must still be emitted. Testing display width here dropped that content and left the
// caret column pointing past the (empty) trailing line, tripping the cursor assertion. See issue #270.
if (line.Length > 0 || input[^1] == '\n')
{
lines.Add(new WrappedLine(textIndex - line.Length - charsDroppedFromLine, line.ToString()));
}
Expand Down
3 changes: 3 additions & 0 deletions tests/PrettyPrompt.Tests/KeyPressTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public void KeyPressKeys()
($"27;6;13~", ConsoleKey.Enter, ConsoleModifiers.Control | ConsoleModifiers.Shift),
($"27;1;13~", ConsoleKey.Enter, 0),
($"[27;2;13~", ConsoleKey.Enter, ConsoleModifiers.Shift),
// Non-Enter combinations must also be decoded (not dropped) once modifyOtherKeys is enabled.
($"27;2;99~", ConsoleKey.C, ConsoleModifiers.Shift), // Shift+c (base keycode 99 'c')
($"27;5;97~", ConsoleKey.A, ConsoleModifiers.Control), // Ctrl+a
($"a", ConsoleKey.A, 0),
($"pasted text", ConsoleKey.Insert, ConsoleModifiers.Shift)
};
Expand Down
13 changes: 13 additions & 0 deletions tests/PrettyPrompt.Tests/WordWrappingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ public void WrapEditableCharacters_EmojiVariationSelectorAtBoundary_OccupiesTwoC
);
}

[Fact]
public void WrapEditableCharacters_LoneCombiningMark_KeepsContentAndCursor()
{
// A lone combining mark (Thai tone mark U+0E49, typed without a base character) has zero display width
// but is real content. It must be kept on its line and the caret must land just past it - previously the
// zero-width line was dropped and the caret column ran past the empty trailing line, crashing. See #270.
var text = "้";
var wrapped = WordWrapping.WrapEditableCharacters(new StringBuilder(text), caret: 1, width: 20);

Assert.Equal(new[] { new WrappedLine(0, "้") }, wrapped.WrappedLines);
Assert.Equal(new ConsoleCoordinate(0, 1), wrapped.Cursor);
}

[Fact]
public void WrapWords_GivenLongText_WrapsWords()
{
Expand Down
Loading