Skip to content

Commit a83aaa9

Browse files
gh-153862: Fix curses window.inch() for non-ASCII characters on a wide build (GH-153863)
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(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 33df37d commit a83aaa9

4 files changed

Lines changed: 41 additions & 6 deletions

File tree

Doc/library/curses.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,6 +1321,8 @@ Reading window contents
13211321
The bottom 8 bits are the character proper and the upper bits are the attributes;
13221322
extract them with the :data:`A_CHARTEXT` and :data:`A_ATTRIBUTES` bit-masks,
13231323
and the color pair with :func:`pair_number`.
1324+
The character byte is the locale-encoded byte of the cell's character,
1325+
consistent with :meth:`instr`.
13241326
It cannot represent a cell holding combining characters, a character that does
13251327
not fit in a single byte, or a color pair outside the :func:`color_pair`
13261328
range; use :meth:`in_wch` for those, which returns it as a :class:`complexchar`.

Lib/test/test_curses.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -958,10 +958,9 @@ def test_read_from_window(self):
958958
self.assertRaises(ValueError, stdscr.instr, -2)
959959
self.assertRaises(ValueError, stdscr.instr, 0, 2, -2)
960960
# A non-ASCII character of an 8-bit locale reads back as its encoded
961-
# byte (see _encodable for the set). instr() returns the locale bytes
962-
# for any single-byte character; inch() packs the text into a chtype, so
963-
# on a wide build it only round-trips a Latin-1 codepoint (byte ==
964-
# codepoint).
961+
# byte (see _encodable for the set). Both instr() and inch() return the
962+
# locale byte for any character that fits the locale's single-byte
963+
# encoding.
965964
encoding = stdscr.encoding
966965
for ch in ('A', 'é', '¤', '€', 'є'):
967966
try:
@@ -973,8 +972,7 @@ def test_read_from_window(self):
973972
with self.subTest(ch=ch):
974973
stdscr.addstr(2, 0, ch)
975974
self.assertEqual(stdscr.instr(2, 0, 1), b)
976-
if ord(ch) < 0x100:
977-
self.assertEqual(stdscr.inch(2, 0) & curses.A_CHARTEXT, b[0])
975+
self.assertEqual(stdscr.inch(2, 0) & curses.A_CHARTEXT, b[0])
978976

979977
def test_coordinate_errors(self):
980978
# Addressing a cell outside the window raises curses.error.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
On a wide :mod:`curses` build, :meth:`curses.window.inch` now returns the
2+
locale-encoded byte of a non-ASCII character, matching
3+
:meth:`~curses.window.instr`, instead of the low byte of its code point.

Modules/_cursesmodule.c

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,30 @@ curses_getcchar(const cchar_t *wcval, wchar_t *wstr, attr_t *attrs, int *pair)
774774
return rtn;
775775
}
776776

777+
/* winch() returns the low 8 bits of the character's code point with no locale
778+
conversion, unlike instr(), so recover the locale byte from the wide cell
779+
when the character maps to exactly one byte, keeping the attribute and color
780+
bits in RTN. A character with no single-byte form is left to winch(). */
781+
static chtype
782+
curses_cell_locale_byte(chtype rtn, const cchar_t *cell)
783+
{
784+
wchar_t wstr[CCHARW_MAX + 1];
785+
attr_t attrs;
786+
int pair;
787+
if (curses_getcchar(cell, wstr, &attrs, &pair) == ERR
788+
|| wstr[0] == L'\0' || wstr[1] != L'\0')
789+
{
790+
return rtn;
791+
}
792+
/* wctob() mirrors ncurses' own _nc_to_char(): the single-byte form, or EOF
793+
when the character has none in this locale. */
794+
int byte = wctob(wstr[0]);
795+
if (byte != EOF) {
796+
rtn = (rtn & ~(chtype)A_CHARTEXT) | (unsigned char)byte;
797+
}
798+
return rtn;
799+
}
800+
777801
/* Hash one cell by value (text, attributes, pair) -- consistent with the
778802
equality comparison, not the raw cchar_t whose padding and unused text tail
779803
it ignores. Zero the key first so those bytes are deterministic, then
@@ -3609,6 +3633,14 @@ _curses_window_inch_impl(PyCursesWindowObject *self, int group_right_1,
36093633
curses_window_set_error(self, funcname, "inch");
36103634
return NULL;
36113635
}
3636+
#ifdef HAVE_NCURSESW
3637+
curses_cell_t cell = {0};
3638+
if ((group_right_1 ? mvwin_wch(self->win, y, x, &cell)
3639+
: win_wch(self->win, &cell)) != ERR)
3640+
{
3641+
rtn = curses_cell_locale_byte(rtn, &cell);
3642+
}
3643+
#endif
36123644
return PyLong_FromUnsignedLong(rtn);
36133645
}
36143646

0 commit comments

Comments
 (0)