From ad5d309d1f4aa4720543c7d12420658b3ab9d6cc Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sat, 6 Jun 2026 18:10:55 +0700 Subject: [PATCH 1/2] Handle variant selector 16 (emoji) --- src/PrettyPrompt/Documents/WordWrapping.cs | 11 ++++++----- src/PrettyPrompt/Rendering/UnicodeWidth.cs | 7 +++++++ tests/PrettyPrompt.Tests/UnicodeWidthTests.cs | 10 ++++++++++ tests/PrettyPrompt.Tests/WordWrappingTests.cs | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/PrettyPrompt/Documents/WordWrapping.cs b/src/PrettyPrompt/Documents/WordWrapping.cs index 2f609a0..7624872 100644 --- a/src/PrettyPrompt/Documents/WordWrapping.cs +++ b/src/PrettyPrompt/Documents/WordWrapping.cs @@ -47,11 +47,12 @@ public static WordWrappedText WrapEditableCharacters(ReadOnlyStringBuilder input bool isCursorPastCharacter = caret > textIndex; Debug.Assert(character != '\t', "tabs should be replaced by spaces"); - // Zero-width scalars (combining marks, zero-width joiners, variation selectors, etc.) are - // legitimate input - e.g. when pasting an emoji such as 🤦🏼‍♂️. They contribute no display - // width (so they don't grow the line or trigger wrapping), but they ARE part of the - // string/line content, so they must still advance textIndex (the string index) and the - // caret column just like any other character. See issue #270. + // Zero-width scalars (combining marks, zero-width joiners, etc.) are legitimate input - e.g. + // when pasting an emoji such as 🤦🏼‍♂️. They contribute no display width (so they don't grow the + // line or trigger wrapping), but they ARE part of the string/line content, so they must still + // advance textIndex (the string index) and the caret column just like any other character. + // (The emoji variation selector U+FE0F is the exception: GetWidth gives it one column, because + // it promotes its base to a 2-column emoji - see UnicodeWidth.) See issue #270. int unicodeWidth = UnicodeWidth.GetWidth(character); currentLineLength += unicodeWidth; textIndex++; diff --git a/src/PrettyPrompt/Rendering/UnicodeWidth.cs b/src/PrettyPrompt/Rendering/UnicodeWidth.cs index 5af5565..75f3e26 100644 --- a/src/PrettyPrompt/Rendering/UnicodeWidth.cs +++ b/src/PrettyPrompt/Rendering/UnicodeWidth.cs @@ -38,6 +38,9 @@ public static int GetWidth(char character) { if (character == '\n') return 1; // PrettyPrompt: treat newline as occupying a single column. if (char.IsSurrogate(character)) return 1; // half of a surrogate pair; the pair sums to the scalar's width. + // U+FE0F (emoji variation selector) carries the column its base gains from emoji presentation - e.g. + // ⚠ (1 col) + VS16 = a 2-col emoji - keeping this per-char sum equal to GetGraphemeClusterWidth. + if (character == (char)0xFE0F) return 1; return Clamp(UnicodeCalculator.GetWidth(character)); } @@ -70,6 +73,10 @@ public static int GetGraphemeClusterWidth(ReadOnlySpan cluster) return 1; // ill-formed (e.g. a lone surrogate); be defensive and reserve a single column. } if (baseRune.Value == '\n') return 1; + // U+FE0F (emoji variation selector) forces emoji presentation, which is 2 columns even for a base + // that defaults to 1 - e.g. ⚠ (U+26A0) is 1 column but ⚠️ is a 2-column emoji; wcwidth misses this. + // The length check skips a lone, base-less selector (no width of its own). + if (cluster.Length > 1 && cluster.Contains((char)0xFE0F)) return 2; return Clamp(UnicodeCalculator.GetWidth(baseRune)); } diff --git a/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs b/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs index 1169428..477c26d 100644 --- a/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs +++ b/tests/PrettyPrompt.Tests/UnicodeWidthTests.cs @@ -37,6 +37,14 @@ public class UnicodeWidthTests // narrow supplementary glyph that the old code mis-sized (issue called this out): a playing card is 1 [InlineData("\U0001F0A1", 1)] // 🂡 playing card ace of spades + + // U+FE0F (emoji variation selector) promotes a default-text base into a two-column emoji. The bare base + // is one column, but base + VS16 renders as a 2-column emoji in terminals, so the caret/cursor must size + // it as two columns to stay aligned past it. See https://github.com/waf/PrettyPrompt/issues/270. + [InlineData("⚠", 1)] // ⚠ warning sign, text presentation = 1 column + [InlineData("⚠️", 2)] // ⚠️ warning sign + VS16, emoji presentation = 2 columns + [InlineData("abc⚠️def", 8)] // surrounded: abc (3) + ⚠️ (2) + def (3) + [InlineData("ℹ️", 2)] // ℹ️ information source + VS16 public void GetWidth_ReturnsExpectedDisplayWidth(string text, int expectedWidth) { Assert.Equal(expectedWidth, UnicodeWidth.GetWidth(text)); @@ -50,6 +58,8 @@ public void GetWidth_ReturnsExpectedDisplayWidth(string text, int expectedWidth) [InlineData("\u79B0\U000E0100", 2)] // 禰󠄀 [InlineData("a", 1)] // narrow [InlineData("\u4E66", 2)] // 书 wide + [InlineData("⚠", 1)] // ⚠ warning sign on its own = text presentation, 1 column + [InlineData("⚠️", 2)] // ⚠️ warning sign + VS16 = emoji presentation, 2 columns public void GetGraphemeClusterWidth_IsCappedAtTwo(string cluster, int expectedWidth) { Assert.Equal(expectedWidth, UnicodeWidth.GetGraphemeClusterWidth(cluster)); diff --git a/tests/PrettyPrompt.Tests/WordWrappingTests.cs b/tests/PrettyPrompt.Tests/WordWrappingTests.cs index 074fd3b..afcdc96 100644 --- a/tests/PrettyPrompt.Tests/WordWrappingTests.cs +++ b/tests/PrettyPrompt.Tests/WordWrappingTests.cs @@ -66,6 +66,24 @@ public void WrapEditableCharacters_DoubleWidthCharactersWithWrappingInMiddleOfCh } + [Fact] + public void WrapEditableCharacters_EmojiVariationSelectorAtBoundary_OccupiesTwoColumns() + { + // ⚠️ (U+26A0 U+FE0F) is a 2-column emoji. With width 6, "abcd" (4 cols) + ⚠️ (2 cols) must exactly + // fill the first line - the selector is NOT zero-width here - so "efgh" wraps to the next line. + var text = "abcd⚠️efgh"; + var wrapped = WordWrapping.WrapEditableCharacters(new StringBuilder(text), caret: 0, width: 6); + + Assert.Equal( + new[] + { + new WrappedLine(0, "abcd⚠️"), + new WrappedLine(6, "efgh"), + }, + wrapped.WrappedLines + ); + } + [Fact] public void WrapWords_GivenLongText_WrapsWords() { From 71210cba422e8cb7bf8a4d48660c3b51544b50dd Mon Sep 17 00:00:00 2001 From: Will Fuqua Date: Sat, 6 Jun 2026 18:11:31 +0700 Subject: [PATCH 2/2] Add pretty prompt history-file to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 228c9f1..a653795 100644 --- a/.gitignore +++ b/.gitignore @@ -349,3 +349,5 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ coverage.opencover.xml + +history-file