8000
Skip to content

Bugfix some kinds of emojis with grapheme boundry support - #1100

Merged
penguinolog merged 12 commits into
urwid:masterfrom
jquast:jq/wcwidth-wcswidth-integration
Feb 2, 2026
Merged

Bugfix some kinds of emojis with grapheme boundry support#1100
penguinolog merged 12 commits into
urwid:masterfrom
jquast:jq/wcwidth-wcswidth-integration

Conversation

@jquast
@jquast jquast commented Jan 26, 2026
Copy link
Copy Markdown
Contributor
Checklist
  • I've ensured that similar functionality has not already been implemented
  • I've ensured that similar functionality has not earlier been proposed and declined
  • I've branched off the master branch
  • I've merged fresh upstream into my branch recently
  • I've ran tox successfully in local environment
  • I've included docstrings and/or documentation and/or examples for my code (if this is a new feature)

Problem: urwid processes each unicode point as wide or zero, but is missing support for grapheme clustering, several kinds of common unicode strings "in sequence", like hearts, flags, etc:

café
🇨🇦
👋🏻
❤

Reproduce: Easy to experience but hard to capture by video/screenshot. Copy docs/examples/edit_text.txt and edit using urwid:

    python examples/edit.py docs/examples/edit_text.txt

The cursor moves into invisible areas between the emojis and makes them difficult to navigate and edit, if you know that they contain hidden sequences, you can edit around them with successful care, though!

Solution: Integrate with wcwidth>=0.4 which adds grapheme cluster support with wcwidth.iter_graphemes() (in 0.3.0) and function wcwidth.grapheme_boundary_before() (0.4.0, added for this PR).

By careful integration with grapheme clustering, I am able to "navigate" the cursor directly from one emoji to the next, column by column, without any strange invisible/non-movement behaviors.

This requires integration with wcwidth>=0.3 with new iter_graphemes()
function.

It is true, though, that sometimes measuring things individually by a
character can accidentally be correct, so some tests do succeed,
Uses ``wcwidth.iter_graphemes()`` and a dumb hack to go previous
grapheme for now, I am working on a new
wcwidth.grapheme_boundary_before() function, maybe in wcwidth>=0.4.

WIP, still in testing
@github-actions github-actions Bot added docs Issues related to documentation Tests Tests labels Jan 26, 2026
@jquast jquast changed the title Bugfix/Improve grapheme boundry, emojis etc. support Bugfix some kinds of emojis with grapheme boundry support Jan 26, 2026
@jquast
jquast marked this pull request as ready for review January 26, 2026 03:02
Comment thread examples/edit.py
def __del__(self) -> None:
self.file.close()
if self.file is not None:
self.file.close()
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whenever I left the editor with F8 key I had a Nonetype exception here, I'm sure it has to do with race condition of __del__ at any unexpected time depending on python version and platform, anyway I just added this to remove the exception

@coveralls
coveralls commented Jan 26, 2026
Copy link
Copy Markdown

Pull Request Test Coverage Report for Build 21433031372

Details

  • 41 of 46 (89.13%) changed or added relevant lines in 3 files are covered.
  • 2 unchanged lines in 1 file lost coverage.
  • Overall coverage decreased (-0.04%) to 74.071%

Changes Missing Coverage Covered Lines Changed/Added Lines %
urwid/str_util.py 34 39 87.18%
Files with Coverage Reduction New Missed Lines %
urwid/str_util.py 2 77.62%
Totals Coverage Status
Change from base Build 21351087236: -0.04%
Covered Lines: 9245
Relevant Lines: 12510

💛 - Coveralls

@ulidtko ulidtko left a comment
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, @jquast !

This'd be lovely to merge. Keeping in mind the obvious improvement to correctness; can we quantify the performance hit? Is it measurable?

@ulidtko
ulidtko commented Jan 26, 2026
Copy link
Copy Markdown
Collaborator

@jquast would be great to get your feedback in #1099 as well, please take a look

Added some raw control characters to docs/examples/edit_text.txt, there
is a strange effect of the cursor, it will not "move" into them, as each
cell is measured as 0 -- but at least it doesn't crash!

(For an editor to handle control characters, it needs to change the
displayed presentation, eg \x00 -> "^A" and usually bolded by tradition,
it also needs to allow input, eg "^V^A")
@jquast
jquast commented Jan 26, 2026
Copy link
Copy Markdown
Contributor Author

I changed the PR a bit to ignore control codes 30b4da6 and added a line of control codes to the sample txt file, per @penguinolog's suggestion and benchmarked per @ulidtko's request using pytest-benchmark, source and image attached.

Up to 5-20% slower, probably depends on complexity of data. Test data is udhr_combined.txt

Source of benchmark_text_measurement.py
#!/usr/bin/env python
import os
from urwid.str_util import calc_width, calc_text_pos, move_prev_char, move_next_char

# using UDHR test file from 'wcwidth' project (mix of world languages)
UDHR_FILE = os.path.join(os.path.dirname(__file__), '..', 'wcwidth', 'tests', 'udhr_combined.txt')
with open(UDHR_FILE, encoding='utf-8') as f:
    UDHR_TEXT = f.read()
UDHR_LINES = [line for line in UDHR_TEXT.splitlines() if line.strip()]

def test_calc_width(benchmark):
    text = UDHR_TEXT
    length = len(text)
    def work():
        for _ in range(8):
            calc_width(text, 0, length)

    benchmark(work)


def test_calc_text_pos(benchmark):
    lines = UDHR_LINES[:200]
    def work():
        for _ in range(200):
            for line in lines:
                calc_text_pos(line, 0, len(line), 40)

    benchmark(work)


def test_move_next_char(benchmark):
    text = UDHR_TEXT[:20000]
    end = len(text)
    def work():
        for _ in range(100):
            pos = 0
            while pos < end:
                pos = move_next_char(text, pos, end)

    benchmark(work)


def test_move_prev_char(benchmark):
    text = UDHR_TEXT[:20000]
    start = 0
    def work():
        for _ in range(100):
            pos = len(text)
            while pos > start:
                pos = move_prev_char(text, start, pos)

    benchmark(work)

Note str_utils.py from master branch on left, this PR is on right.

calc_width performance

calc_width

calc_text_pos performance

calc_text_pos

move_next_char performance

move_next_char

move_prev_char performance

move_prev_char

This one does not make sense, I might have made an error or some local effect of my computer?

Comment thread urwid/str_util.py
Comment thread urwid/str_util.py
Comment thread urwid/str_util.py
Comment thread urwid/str_util.py

cols = 0
for idx in range(start_offs, end_offs):
width = get_char_width(text[idx])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we remove get_char_width completely so that we're not calculating widths different ways in different parts of the code?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to do it, but it may be used by dependent packages

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to do it, but it may be used by dependent packages

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this PR get_char_width is still being used in text_layout.py and font.py, we could at least remove those and mark the function as deprecated

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All internal uses now replaced wcwidth.width(control_codes='ignore') and has .. deprecated:: 0.3.4

@jquast
jquast commented Jan 27, 2026
Copy link
Copy Markdown
Contributor Author

Thanks so much for the review, I didn't understand how the if-utf8-bytes branches are used in a practical way so I did not give it attention, I will study and address all feedback!

@jquast
jquast force-pushed the jq/wcwidth-wcswidth-integration branch from c7a75b8 to 820efff Compare January 28, 2026 01:13
Comment thread urwid/str_util.py
Comment thread urwid/str_util.py
Comment thread urwid/str_util.py
Comment thread urwid/str_util.py
@penguinolog
penguinolog merged commit f742ad8 into urwid:master Feb 2, 2026
20 checks passed
@jquast
jquast deleted the jq/wcwidth-wcswidth-integration branch May 24, 2026 20:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug docs Issues related to documentation Tests Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants

0