spectreconsole/spectre.console

Some characters break table output

Opened this issue · 2 comments

Information

  • OS: MacOS
  • Version: 15.5
  • Terminal: iTerm2 3.5.14

Describe the bug
Some characters like emoji have different width than usual and break the table in CLI programs.

To Reproduce
This is a demo project that reproduces the issue:

How to create it:

dotnet new console -n SpectreDemo
cd SpectreDemo
dotnet add package Spectre.Console

Replace all content inside Program.cs with this one:

using System;
using Spectre.Console;

class Program
{
    static void Main()
    {
        // Enable UTF-8 so emojis appear in Windows Console/PowerShell
        Console.OutputEncoding = System.Text.Encoding.UTF8;

        // ── Create the “Video Info” table ───────────────────────────────
        var info = new Table()
            .Border(TableBorder.Rounded)   // same look as TwitchDownloaderCLI
            .Expand()                      // use all available width
            .AddColumns("Key", "Value");

        info.AddRow("Streamer",  "BlueRangerPeter");
        info.AddRow("Title",
            "Frankie prob poopin' 💩 or sum sum idk 🤷‍♀️ I just love runnin' 🏃‍♂️ " +
            "n not dyin' 💀 from evil bunny robots 🐰🤖 n' shiiii'");
        info.AddRow("Length",     "2:17:00");
        info.AddRow("Category",   "Finding Frankie");
        info.AddRow("Views",      "19");
        info.AddRow("Created at", "2025-06-09 03:34:56 UTC");
        info.AddRow("Description","-");        // <-- shorter than the title

        // Render to the console
        AnsiConsole.Write(info);
    }
}

Then run it:

% dotnet run
╭──────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Key          │ Value                                                                                                                    │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Streamer     │ BlueRangerPeter                                                                                                          │
│ Title        │ Frankie prob poopin' 💩 or sum sum idk 🤷‍♀️ I just love runnin' 🏃‍♂️ n not dyin' 💀 from evil bunny robots 🐰🤖 n' shiiii' │
│ Length       │ 2:17:00                                                                                                                  │
│ Category     │ Finding Frankie                                                                                                          │
│ Views        │ 19                                                                                                                       │
│ Created at   │ 2025-06-09 03:34:56 UTC                                                                                                  │
│ Description  │ -                                                                                                                        │
╰──────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Expected behavior
Print the table correctly like this, notice there's still a small difference because I used spaces to mimic the missing distance:

% dotnet run
╭──────────────┬──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Key          │ Value                                                                                                                    │
├──────────────┼──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ Streamer     │ BlueRangerPeter                                                                                                          │
│ Title        │ Frankie prob poopin' 💩 or sum sum idk 🤷‍♀️ I just love runnin' 🏃‍♂️ n not dyin' 💀 from evil bunny robots 🐰🤖 n' shiiii'     │
│ Length       │ 2:17:00                                                                                                                  │
│ Category     │ Finding Frankie                                                                                                          │
│ Views        │ 19                                                                                                                       │
│ Created at   │ 2025-06-09 03:34:56 UTC                                                                                                  │
│ Description  │ -                                                                                                                        │
╰──────────────┴──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

Screenshots
Not required

Additional context
Not required

I would suggest to change the method Cell::GetCellLength in src/Spectre.Console/Internal/Cell.cs to use something like new StringInfo(text).LengthInTextElements;. I think that would work better with complex emoji strings.

Here is a proof of concept:

Screenshot

Image

Code

using System.Globalization;
using System.Text;
using Wcwidth;

Console.OutputEncoding = Encoding.UTF8;

List<string> names = [
    "This is fun 🏆🏃‍♀️🏁",
    "Another example 🐣🐥🦅",
    "Hello world 👁️👗👁️",
    "Short 👷🖌️🔧",
    "One Two Three Four Five ♾️🗿🔥",
    "nothing interesting here",
    "slightly more interesting 🚲",
    "OMG lol 🍣♻️"
    ];


Console.WriteLine("GetCellLength using Wcwidth UnicodeCalculator:");
Console.WriteLine();
int maxlength = names.Max(x => GetCellLength(x));
foreach (var name in names)
{
    Console.WriteLine(name + new string(' ', maxlength - GetCellLength(name)) + " |");
}
Console.WriteLine();
Console.WriteLine();

Console.WriteLine("GetCellLength using StringInfo LengthInTextElements and Emoji counting twice:");
Console.WriteLine();
maxlength = names.Max(x => GetCellLengthNew(x));
foreach (var name in names)
{
    Console.WriteLine(name + new string(' ', maxlength - GetCellLengthNew(name)) + " |");
}

static int CountEmoji(StringInfo inputString)
{
    int count = 0;

    for (int i = 0; i < inputString.LengthInTextElements; i++)
    {
        if (IsEmoji(inputString, i))
        {
            count++;
        }
    }

    return count;
}

static bool IsEmoji(StringInfo inputString, int index)
{
    var firstUnicodeChar = inputString.SubstringByTextElements(index, 1); // gets the char at the given index
    var charCode = Char.ConvertToUtf32(firstUnicodeChar, 0); // gets a numeric value for this char; note: we first get the char by index rather than just passing the index as an additional argument here since if there are additional utf32 chars earlier in the string our index would be offset
    return IsEmoticon(charCode)
    || IsMiscPictograph(charCode)
    || IsTransport(charCode)
    || IsMiscSymbol(charCode)
    || IsDingbat(charCode)
    || IsVariationSelector(charCode)
    || IsSupplemental(charCode)
    || IsFlag(charCode);
}

static bool IsEmoticon(int charCode) => 0x1F600 <= charCode && charCode <= 0x1F64F;
static bool IsMiscPictograph(int charCode) => 0x1F680 <= charCode && charCode <= 0x1F5FF;
static bool IsTransport(int charCode) => 0x2600 <= charCode && charCode <= 0x1F6FF;
static bool IsMiscSymbol(int charCode) => 0x2700 <= charCode && charCode <= 0x26FF;
static bool IsDingbat(int charCode) => 0x2700 <= charCode && charCode <= 0x27BF;
static bool IsVariationSelector(int charCode) => 0xFE00 <= charCode && charCode <= 0xFE0F;
static bool IsSupplemental(int charCode) => 0x1F900 <= charCode && charCode <= 0x1F9FF;
static bool IsFlag(int charCode) => 0x1F1E6 <= charCode && charCode <= 0x1F1FF;

static int GetCellLengthNew(string text)
{
    var inputString = new StringInfo(text);
    int len = inputString.LengthInTextElements;
    int emojiCount = CountEmoji(inputString);

    return len + emojiCount;    // Emojis take 2 characters wide, so we count them twice
}


// Legacy way of counting lengh
static int GetCellLength(string text)
{
    var sum = 0;
    for (var index = 0; index < text.Length; index++)
    {
        var rune = text[index];
        sum += GetCellLengthOfChar(rune);
    }

    return sum;
}

static int GetCellLengthOfChar(char rune)
{
    if (rune == '\n')
    {
        return 1;
    }

    return (sbyte)UnicodeCalculator.GetWidth(rune);
}