From: Serhiy Storchaka Date: Sun, 19 Jul 2026 15:19:15 +0000 (+0300) Subject: [3.14] gh-153862: Fix curses window.inch() for non-ASCII characters on a wide build... X-Git-Url: http://git.ipfire.org/index.cgi?a=commitdiff_plain;h=1933f77772b8bcbbb308a0efc9767c096015e688;p=thirdparty%2FPython%2Fcpython.git [3.14] gh-153862: Fix curses window.inch() for non-ASCII characters on a wide build (GH-153863) (GH-154151) On a wide build, winch() returns the low 8 bits of the character's code point with no locale conversion, so inch()/mvinch() disagreed with instr() for a non-ASCII character of an 8-bit locale ('€' under ISO-8859-15 gave 0xAC instead of 0xA4). Re-encode the cell to its locale byte via wctob(), as ncurses does for getbkgd(). (cherry picked from commit a83aaa91490d5885949eb0780a37b15963b6d0f3) Co-authored-by: Claude Opus 4.8 --- diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 77c9397faeba..853ff6ab7332 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -1082,6 +1082,8 @@ Window objects The bottom 8 bits are the character proper and the upper bits are the attributes; extract them with the :data:`A_CHARTEXT` and :data:`A_ATTRIBUTES` bit-masks, and the color pair with :func:`pair_number`. + The character byte is the locale-encoded byte of the cell's character, + consistent with :meth:`instr`. .. method:: window.insch(ch[, attr]) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index e15817ce2e7b..f4b30ac9957a 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -506,10 +506,9 @@ class TestCurses(unittest.TestCase): self.assertRaises(ValueError, stdscr.instr, -2) self.assertRaises(ValueError, stdscr.instr, 0, 2, -2) # A non-ASCII character of an 8-bit locale reads back as its encoded - # byte (see _encodable for the set). instr() returns the locale bytes - # for any single-byte character; inch() packs the text into a chtype, so - # on a wide build it only round-trips a Latin-1 codepoint (byte == - # codepoint). + # byte (see _encodable for the set). Both instr() and inch() return the + # locale byte for any character that fits the locale's single-byte + # encoding. encoding = stdscr.encoding for ch in ('A', 'é', '¤', '€', 'є'): try: @@ -521,8 +520,7 @@ class TestCurses(unittest.TestCase): with self.subTest(ch=ch): stdscr.addstr(2, 0, ch) self.assertEqual(stdscr.instr(2, 0, 1), b) - if ord(ch) < 0x100: - self.assertEqual(stdscr.inch(2, 0) & curses.A_CHARTEXT, b[0]) + self.assertEqual(stdscr.inch(2, 0) & curses.A_CHARTEXT, b[0]) def test_coordinate_errors(self): # Addressing a cell outside the window raises curses.error. diff --git a/Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst b/Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst new file mode 100644 index 000000000000..aa646dc6c491 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-17-20-31-00.gh-issue-153862.X3kjoY.rst @@ -0,0 +1,3 @@ +On a wide :mod:`curses` build, :meth:`curses.window.inch` now returns the +locale-encoded byte of a non-ASCII character, matching +:meth:`~curses.window.instr`, instead of the low byte of its code point. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 51c4854aa5da..41c20a7f96cd 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -1881,6 +1881,34 @@ _curses_window_insch_impl(PyCursesWindowObject *self, int group_left_1, return PyCursesCheckERR_ForWin(self, rtn, "insch"); } +#ifdef HAVE_NCURSESW +/* winch() returns the low 8 bits of the character's code point with no locale + conversion, unlike instr(), so recover the locale byte from the wide cell + when the character maps to exactly one byte, keeping the attribute and color + bits in RTN. A character with no single-byte form is left to winch(). */ +static chtype +curses_cell_locale_byte(chtype rtn, const cchar_t *cell) +{ + wchar_t wstr[CCHARW_MAX + 1]; + attr_t attrs; + short pair; + /* getcchar() is not guaranteed to write the text of an empty cell. */ + wstr[0] = L'\0'; + if (getcchar(cell, wstr, &attrs, &pair, NULL) == ERR + || wstr[0] == L'\0' || wstr[1] != L'\0') + { + return rtn; + } + /* wctob() mirrors ncurses' own _nc_to_char(): the single-byte form, or EOF + when the character has none in this locale. */ + int byte = wctob(wstr[0]); + if (byte != EOF) { + rtn = (rtn & ~(chtype)A_CHARTEXT) | (unsigned char)byte; + } + return rtn; +} +#endif + /*[clinic input] _curses.window.inch -> unsigned_long @@ -1911,7 +1939,14 @@ _curses_window_inch_impl(PyCursesWindowObject *self, int group_right_1, else { rtn = mvwinch(self->win, y, x); } - +#ifdef HAVE_NCURSESW + cchar_t cell = {0}; + if ((group_right_1 ? mvwin_wch(self->win, y, x, &cell) + : win_wch(self->win, &cell)) != ERR) + { + rtn = curses_cell_locale_byte(rtn, &cell); + } +#endif return rtn; }