Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Doc/library/curses.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2645,6 +2645,11 @@ You can instantiate a :class:`Textbox` object as follows:
upper-left corner of the containing window, with coordinates ``(0, 0)``.
The instance's :attr:`stripspaces` flag is initially on.

.. versionchanged:: next
Entering and reading back the full Unicode range, including combining
characters, is now supported when curses is built with wide-character
support.

:class:`Textbox` objects have the following methods:


Expand All @@ -2659,6 +2664,10 @@ You can instantiate a :class:`Textbox` object as follows:
string; whether blanks in the window are included is affected by the
:attr:`stripspaces` attribute.

.. versionchanged:: next
*validate* is now called with a non-ASCII character as a string;
other keystrokes are still passed as an integer.


.. method:: do_command(ch)

Expand Down
5 changes: 5 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,11 @@ curses
against an ncurses with ``NCURSES_EXT_FUNCS``.
(Contributed by Serhiy Storchaka in :gh:`152334`.)

* :class:`curses.textpad.Textbox` now supports entering and reading back the
full Unicode range, including combining characters, when curses is built with
wide-character support.
(Contributed by Serhiy Storchaka in :gh:`133031`.)

gzip
----

Expand Down
71 changes: 36 additions & 35 deletions Lib/curses/textpad.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,32 +57,13 @@ def _update_max_yx(self):
self.maxx = maxx - 1

def _decode(self, ch):
# The text of a chtype cell or input byte, decoded with the window's
# encoding. A_CHARTEXT keeps the character byte, dropping the attributes.
# Decode an integer keystroke or byte to text with the window's encoding.
# A_CHARTEXT drops any attribute bits.
return bytes([ch & curses.A_CHARTEXT]).decode(self.win.encoding, 'replace')

def _char_at(self, *yx):
# The text of the cell at the given position (default: the cursor).
# instr() re-encodes it to the window's encoding; inch() cannot
# represent a non-ASCII 8-bit-locale character on a wide build.
return self.win.instr(*yx, 1).decode(self.win.encoding, 'replace')

def _cell_at(self, *yx):
# The cell at the given position (default: the cursor) as a chtype
# addch() can write back with its rendition. inch() mangles a non-ASCII
# character on a wide build, so take the byte from instr() and the
# attributes from inch().
return self.win.instr(*yx, 1)[0] | self.win.inch(*yx) & curses.A_ATTRIBUTES

def _isprint(self, cell):
# Whether a chtype cell holds a printable character; _decode() drops the
# attribute bits.
return self._decode(cell).isprintable()

def _printable_key(self, ch):
# Whether the integer keystroke is a printable character, not a key
# code. 0..255 are character bytes (decoded with the window's encoding);
# larger values are function and navigation keys.
# Whether the integer keystroke is a printable character, not a key code:
# 0..255 are character bytes, larger values are function keys.
return ch <= 0xff and self._decode(ch).isprintable()

def _end_of_line(self, y):
Expand All @@ -91,7 +72,8 @@ def _end_of_line(self, y):
self._update_max_yx()
last = self.maxx
while True:
if self._char_at(y, last) != ' ':
# The text of the cell at (y, last).
if str(self.win.in_wch(y, last)) != ' ':
last = min(self.maxx, last+1)
break
elif last == 0:
Expand All @@ -105,16 +87,22 @@ def _insert_printable_char(self, ch):
backyx = None
while True:
if self.insert_mode:
oldch = self._cell_at()
# The displaced cell, as a complexchar so addch() can rewrite it
# with its rendition.
oldch = self.win.in_wch()
if y >= self.maxy and x >= self.maxx:
# Use insch() in the lower-right cell: addch() there would move
# the cursor out of the window, raising an error and scrolling
# a scrollable window. Pass it as text: insch() does not decode
# an int byte through the locale on a wide build.
self.win.insch(self._decode(ch), ch & curses.A_ATTRIBUTES)
# Use insch() in the lower-right cell; addch() there would push
# the cursor out of the window (an error, and it scrolls a
# scrollable window). insch() does not decode an int byte
# through the locale on a wide build, so pass it as text.
if isinstance(ch, int):
self.win.insch(self._decode(ch), ch & curses.A_ATTRIBUTES)
else:
self.win.insch(ch)
break
self.win.addch(ch)
if not self.insert_mode or not self._isprint(oldch):
# In insert mode keep shifting cells right until a blank one.
if not self.insert_mode or not str(oldch).isprintable():
break
ch = oldch
(y, x) = self.win.getyx()
Expand All @@ -130,9 +118,17 @@ def do_command(self, ch):
self._update_max_yx()
(y, x) = self.win.getyx()
self.lastcmd = ch
if self._printable_key(ch):
if isinstance(ch, str):
# A character from get_wch(); a control character is dispatched
# below by its code point.
if ch.isprintable():
self._insert_printable_char(ch)
return 1
ch = ord(ch)
elif self._printable_key(ch):
self._insert_printable_char(ch)
elif ch == curses.ascii.SOH: # ^a
return 1
if ch == curses.ascii.SOH: # ^a
self.win.move(y, 0)
elif ch in (curses.ascii.STX,curses.KEY_LEFT,
curses.ascii.BS,
Expand Down Expand Up @@ -204,15 +200,20 @@ def gather(self):
for x in range(self.maxx+1):
if self.stripspaces and x > stop:
break
result = result + self._char_at(y, x)
result = result + str(self.win.in_wch(y, x))
if self.maxy > 0:
result = result + "\n"
return result

def edit(self, validate=None):
"Edit in the widget window and collect the results."
while 1:
ch = self.win.getch()
ch = self.win.get_wch()
# Represent an ASCII keystroke by its code point, the way getch()
# always has, so that existing validators and the command dispatch
# keep working; only non-ASCII characters are passed as strings.
if isinstance(ch, str) and ch.isascii():
ch = ord(ch)
if validate:
ch = validate(ch)
if not ch:
Expand Down
62 changes: 54 additions & 8 deletions Lib/test/test_curses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2247,9 +2247,9 @@ def test_textbox_fill_last_cell_scrollok(self):
self.assertEqual(box.gather(), 'abc\ndef\n')

def test_textbox_8bit(self):
# A character of an 8-bit locale encoding is entered and read back
# through the byte API. The byte path also runs on a wide build, so the
# test is not skipped there. Run the suite under an 8-bit locale
# An 8-bit-locale character is entered as integer bytes -- the way
# do_command() receives getch() input -- and read back; runs on both
# builds. Run the suite under an 8-bit locale
# (ISO-8859-1, ISO-8859-15 or KOI8-U) to reach the non-ASCII cases; each
# string is used only if the encoding maps it to single bytes. 'abc' is
# ASCII, 'café' is common to the Latin encodings, and the rest are
Expand All @@ -2270,9 +2270,8 @@ def test_textbox_8bit(self):

def test_textbox_8bit_insert(self):
# Insert mode shifts the rest of the line right by reading each cell back
# and rewriting it; a non-ASCII 8-bit-locale character must survive the
# shift, even on a wide build where inch() mangles it. See
# test_textbox_8bit for the character choices.
# and rewriting it; an 8-bit-locale character entered as bytes must
# survive the shift. See test_textbox_8bit for the character choices.
encoding = self.stdscr.encoding
for ch in ['é', '¤', '€', 'є']:
try:
Expand All @@ -2290,8 +2289,8 @@ def test_textbox_8bit_insert(self):
self.assertEqual(box.gather(), 'ab' + ch + 'c ')

def test_textbox_8bit_fill_last_cell(self):
# A non-ASCII 8-bit-locale character must survive being written to the
# lower-right cell, which uses insch() rather than addch(). See
# An 8-bit-locale character entered as bytes must survive being written
# to the lower-right cell, which uses insch() rather than addch(). See
# test_textbox_8bit for the character choices.
encoding = self.stdscr.encoding
for ch in ['é', '¤', '€', 'є']:
Expand All @@ -2308,6 +2307,53 @@ def test_textbox_8bit_fill_last_cell(self):
box.do_command(byte)
self.assertEqual(box.gather(), text)

def test_textbox_unicode(self):
# Like test_textbox_8bit, but characters are entered as strings -- the
# way do_command() receives get_wch() input -- rather than integer
# bytes. Each string is used only if encodable in the current locale.
for text in ['abc', 'héšλ', 'café', 'naïve ¤', 'soupçon €Š', 'дякую єі']:
if self._encodable(text):
with self.subTest(text=text):
box, win = self._make_textbox(1, 12)
for ch in text:
box.do_command(ch)
self.assertEqual(box.gather(), text + ' ')

def test_textbox_unicode_insert_mode(self):
# Like test_textbox_8bit_insert, but the character is entered as a string
# (get_wch() input). Each string is used only if encodable.
for text in ['abcd', 'aβλc', 'aéàc', 'a¤½c', 'a€Šc', 'aдві']:
if self._encodable(text):
with self.subTest(text=text):
box, win = self._make_textbox(1, 10, insert_mode=True)
for ch in text[0] + text[2:]: # all but the 2nd character
box.do_command(ch)
win.move(0, 1)
box.do_command(text[1]) # insert it at position 1
self.assertEqual(box.gather(), text + ' ')

@requires_wide_build
def test_textbox_combining(self):
# A spacing character plus a combining mark is a single cell, which
# needs the wide build (a narrow build stores one byte per cell).
text = 'e\u0301' # 'e' + COMBINING ACUTE ACCENT
if self._encodable(text):
box, win = self._make_textbox(1, 10)
for ch in text:
box.do_command(ch)
self.assertEqual(box.gather(), text + ' ')

def test_textbox_edit_wide(self):
# edit() reads characters through get_wch(). Each is used only if
# encodable in the current locale.
for ch in ['A', 'é', '¤', '€', 'д']:
if self._encodable(ch):
with self.subTest(ch=ch):
box, win = self._make_textbox(1, 10)
for c in reversed(['a', ch, chr(curses.ascii.BEL)]):
curses.unget_wch(c)
self.assertEqual(box.edit(), 'a' + ch + ' ')

def test_textbox_movement(self):
box, win = self._make_textbox(3, 10)
self._type(box, 'abc')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
:class:`curses.textpad.Textbox` now supports entering and reading back the full
Unicode range, including combining characters, when curses is built with
wide-character support.
Loading