rich icon indicating copy to clipboard operation
rich copied to clipboard

[BUG] Variation Selector U+FE0F not accounted for in cell width calculation

Open patrick91 opened this issue 1 month ago â€ĸ 4 comments

  • [x] I've checked docs and closed issues for possible solutions.
  • [x] I can't find my issue in the FAQ.

I was working on a library and noticed additional lines, in the output, like this:

Image

I've debugged this with Claude, and looks like rich's cell_len function does not account for the variation selector U+FE0F (VS-16), which requests emoji presentation. When a narrow character (East Asian Width = Narrow) is followed by VS-16, it should be rendered as a 2-cell-wide emoji, but Rich reports it as 1 cell.

By the way, this seems to ok in Mac's default terminal, I'm assuming because they don't really care about the variation selector? 🤔

See (ghostty top and mac os terminal at the bottom)

Image

Reproduction

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "rich",
#     "wcwidth",
# ]
# ///
from rich.cells import cell_len
from rich.console import Console
from rich.text import Text
from rich.live_render import LiveRender
import wcwidth

# Characters with variation selector (VS-16 requests emoji presentation)
test_cases = [
    ("âŦ‡", "arrow without VS"),
    ("âŦ‡ī¸", "arrow with VS (U+2B07 U+FE0F)"),
    ("â™ģ", "recycle without VS"),
    ("â™ģī¸", "recycle with VS (U+267B U+FE0F)"),
    ("📌", "pushpin (already wide)"),
]

print("=" * 60)
print("Cell width comparison")
print("=" * 60)
print()
print("Character | wcwidth | Rich cell_len | Expected")
print("----------|---------|---------------|----------")
for char, desc in test_cases:
    wc = wcwidth.wcswidth(char)
    rc = cell_len(char)
    expected = 2 if "with VS" in desc or "wide" in desc else 1
    match = "✓" if rc == expected else "✗"
    print(f"{char} {desc:30} | {wc:7} | {rc:13} | {expected} {match}")

print()
print("=" * 60)
print("Visual bug: LiveRender pads lines to terminal width")
print("=" * 60)
print()
print("Lines with VS-16 emojis wrap incorrectly because Rich thinks")
print("the emoji is 1 cell but the terminal renders it as 2 cells.")
print()

console = Console()

menu = Text(justify="left")
options = [
    ("💚", "Fix CI Build"),
    ("âŦ‡ī¸", "Downgrade dependencies"),  # BUG: will wrap
    ("âŦ†ī¸", "Upgrade dependencies"),    # BUG: will wrap
    ("📌", "Pin dependencies"),
]
for i, (emoji, desc) in enumerate(options):
    is_last = i == len(options) - 1
    menu.append(Text.assemble("○ ", emoji, "  ", desc, "\n" if not is_last else ""))

live_render = LiveRender(menu)
console.print(live_render)

print()
print("If lines with âŦ‡ī¸ and âŦ†ī¸ wrapped to a new line, that's the bug.")

Table output:

Character wcwidth Rich cell_len Expected
âŦ‡ arrow without VS 1 1 1 ✓
âŦ‡ī¸ arrow with VS (U+2B07 U+FE0F) 2 1 2 ✗
â™ģ recycle without VS 1 1 1 ✓
â™ģī¸ recycle with VS (U+267B U+FE0F) 2 1 2 ✗
📌 pushpin (already wide) 2 2 2 ✓

Expected Behavior

cell_len("âŦ‡ī¸") should return 2, matching wcwidth.wcswidth("âŦ‡ī¸").

Environment

  • Rich version: (tested with latest)
  • Python: 3.11+
  • Terminal: Ghostty, but affects any terminal that correctly renders VS-16 emojis (not Mac os terminal, at least on mac os 15.6.1)

ature, consider posting a screenshot.

Platform

Click to expand

What platform (Win/Linux/Mac) are you running on? What terminal software are you using?

I may ask you to copy and paste the output of the following commands. It may save some time if you do it now.

If you're using Rich in a terminal:

╭───────────────────────── <class 'rich.console.Console'> ─────────────────────────╮
│ A high level console interface.                                                  │
│                                                                                  │
│ ╭──────────────────────────────────────────────────────────────────────────────╮ │
│ │ <console width=110 ColorSystem.TRUECOLOR>                                    │ │
│ ╰──────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                  │
│     color_system = 'truecolor'                                                   │
│         encoding = 'utf-8'                                                       │
│             file = <_io.TextIOWrapper name='<stdout>' mode='w' encoding='utf-8'> │
│           height = 29                                                            │
│    is_alt_screen = False                                                         │
│ is_dumb_terminal = False                                                         │
│   is_interactive = True                                                          │
│       is_jupyter = False                                                         │
│      is_terminal = True                                                          │
│   legacy_windows = False                                                         │
│         no_color = False                                                         │
│          options = ConsoleOptions(                                               │
│                        size=ConsoleDimensions(width=110, height=29),             │
│                        legacy_windows=False,                                     │
│                        min_width=1,                                              │
│                        max_width=110,                                            │
│                        is_terminal=True,                                         │
│                        encoding='utf-8',                                         │
│                        max_height=29,                                            │
│                        justify=None,                                             │
│                        overflow=None,                                            │
│                        no_wrap=False,                                            │
│                        highlight=None,                                           │
│                        markup=None,                                              │
│                        height=None                                               │
│                    )                                                             │
│            quiet = False                                                         │
│           record = False                                                         │
│         safe_box = True                                                          │
│             size = ConsoleDimensions(width=110, height=29)                       │
│        soft_wrap = False                                                         │
│           stderr = False                                                         │
│            style = None                                                          │
│         tab_size = 8                                                             │
│            width = 110                                                           │
╰──────────────────────────────────────────────────────────────────────────────────╯
╭─── <class 'rich._windows.WindowsConsoleFeatures'> ────╮
│ Windows features available.                           │
│                                                       │
│ ╭───────────────────────────────────────────────────╮ │
│ │ WindowsConsoleFeatures(vt=False, truecolor=False) │ │
│ ╰───────────────────────────────────────────────────╯ │
│                                                       │
│ truecolor = False                                     │
│        vt = False                                     │
╰───────────────────────────────────────────────────────╯
╭────── Environment Variables ───────╮
│ {                                  │
│     'CLICOLOR': None,              │
│     'COLORTERM': 'truecolor',      │
│     'COLUMNS': None,               │
│     'JPY_PARENT_PID': None,        │
│     'JUPYTER_COLUMNS': None,       │
│     'JUPYTER_LINES': None,         │
│     'LINES': None,                 │
│     'NO_COLOR': None,              │
│     'TERM_PROGRAM': 'ghostty',     │
│     'TERM': 'xterm-ghostty',       │
│     'TTY_COMPATIBLE': None,        │
│     'TTY_INTERACTIVE': None,       │
│     'VSCODE_VERBOSE_LOGGING': None │
│ }                                  │
╰────────────────────────────────────╯
platform="Darwin"

patrick91 avatar Nov 27 '25 12:11 patrick91

Thank you for your issue. Give us a little time to review it.

PS. You might want to check the FAQ if you haven't done so already.

Rich was created by Will McGugan. Consider sponsoring Will's work on Rich.

This is an automated reply, generated by FAQtory

github-actions[bot] avatar Nov 27 '25 12:11 github-actions[bot]

Rich doesn't handle multi-codepoint characters. There are other variations like flags, skin tone modifiers, etc.

When I've attempted this in that past, I've been confounded by the fact that terminals don't agree on how wide certain characters and sequences should be. No matter what cell length you pick, it will be wrong on one terminal or another. wcwidth couldn't accurately identify the width of a character sequence for all terminals.

wcwidth might have improved since I last looked at it. If it is handling graphemes correctly now, it might be worth switching Rich to it. Maybe you can try monkey-patching and see what happens?

willmcgugan avatar Nov 28 '25 16:11 willmcgugan

Haven't monkey patched yet, but I took a look at wcwidth, and found this PR: https://github.com/jquast/wcwidth/pull/97 😊

patrick91 avatar Nov 28 '25 23:11 patrick91

Cool. Looks like that could be ported fairly easily.

willmcgugan avatar Nov 29 '25 12:11 willmcgugan

+1, I just drove myself crazy trying to figure out why â„šī¸ caused a line break and ✅ didn't. I can't wait for you to patch this issue so my console will be perfect â¤ī¸. Well done on the project, it's really cool to have formatting like this.

Flamintus avatar Dec 12 '25 16:12 Flamintus