From b13c00b9ed369f8a69128b2eabc905d1e2cdafea Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sat, 13 Jun 2026 14:24:05 +0700 Subject: [PATCH] More Mac OS and general typing improvements - Switch from level-2 to level-1 (https://invisible-island.net/xterm/ctlseqs/ctlseqs.html) to fix handling of uppercase characters - Fix handling of combining characters with zero width that still need to be rendered (e.g. tone marks in Thai) --- .gitignore | 1 + src/PrettyPrompt/Console/AnsiEscapeCodes.cs | 9 ++-- src/PrettyPrompt/Console/KeyPress.cs | 51 +++++++++++++++---- src/PrettyPrompt/Documents/WordWrapping.cs | 6 ++- tests/PrettyPrompt.Tests/KeyPressTests.cs | 3 ++ tests/PrettyPrompt.Tests/WordWrappingTests.cs | 13 +++++ 6 files changed, 68 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index a653795..1f1a6cf 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.user *.userosscache *.sln.docstates +*.idea # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs diff --git a/src/PrettyPrompt/Console/AnsiEscapeCodes.cs b/src/PrettyPrompt/Console/AnsiEscapeCodes.cs index c46a2ff..e4299a0 100644 --- a/src/PrettyPrompt/Console/AnsiEscapeCodes.cs +++ b/src/PrettyPrompt/Console/AnsiEscapeCodes.cs @@ -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 ; ; ~" 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"; /// Index starts at 1. diff --git a/src/PrettyPrompt/Console/KeyPress.cs b/src/PrettyPrompt/Console/KeyPress.cs index d77ba7a..b217e1a 100644 --- a/src/PrettyPrompt/Console/KeyPress.cs +++ b/src/PrettyPrompt/Console/KeyPress.cs @@ -136,10 +136,12 @@ internal static IEnumerable ReadForever(IConsole console) /// /// 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 ), 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. /// private static bool TryMapModifyOtherKeys(string sequence, [NotNullWhen(true)] out KeyPress? keyPress) { @@ -155,21 +157,48 @@ private static bool TryMapModifyOtherKeys(string sequence, [NotNullWhen(true)] o var parts = body[..^1].Split(';'); // ["27", "", ""] 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; } + /// + /// Maps a modifyOtherKeys keycode (the Unicode value of the base key) to a 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. + /// + 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); + } + /// /// Read any remaining key presses in the buffer, including the provided . /// diff --git a/src/PrettyPrompt/Documents/WordWrapping.cs b/src/PrettyPrompt/Documents/WordWrapping.cs index 8920bd7..12cfba6 100644 --- a/src/PrettyPrompt/Documents/WordWrapping.cs +++ b/src/PrettyPrompt/Documents/WordWrapping.cs @@ -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())); } diff --git a/tests/PrettyPrompt.Tests/KeyPressTests.cs b/tests/PrettyPrompt.Tests/KeyPressTests.cs index e55e761..157d154 100644 --- a/tests/PrettyPrompt.Tests/KeyPressTests.cs +++ b/tests/PrettyPrompt.Tests/KeyPressTests.cs @@ -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) }; diff --git a/tests/PrettyPrompt.Tests/WordWrappingTests.cs b/tests/PrettyPrompt.Tests/WordWrappingTests.cs index f3c2776..21e7d3d 100644 --- a/tests/PrettyPrompt.Tests/WordWrappingTests.cs +++ b/tests/PrettyPrompt.Tests/WordWrappingTests.cs @@ -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() {