From eb01440026a32d8424b958954a7010b415736bc4 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 17 Oct 2026 22:36:14 +0300 Subject: [PATCH] gh-157210: Attach a combining mark in the last curses Textbox cell A combining character typed into the lower-right cell of a Textbox no longer takes a cell of its own, pushing its base character off the window edge. In insert mode it no longer shifts the following text. Co-authored-by: Vyron Vasileiadis Co-Authored-By: Claude Opus 5.5 (1M context) --- Lib/curses/textpad.py | 32 ++++++++++++++- Lib/test/test_curses.py | 40 +++++++++++++++++++ ...0-17-19-41-12.gh-issue-157210.o9hycSeJ.rst | 5 +++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-17-19-41-12.gh-issue-157210.o9hycSeJ.rst diff --git a/Lib/curses/textpad.py b/Lib/curses/textpad.py index 31ad37539ca4a42..88d9753765de4f9 100644 --- a/Lib/curses/textpad.py +++ b/Lib/curses/textpad.py @@ -2,6 +2,7 @@ import curses import curses.ascii +import unicodedata def rectangle(win, uly, ulx, lry, lrx): """Draw a rectangle with corners at the provided upper-left @@ -49,6 +50,8 @@ def __init__(self, win, insert_mode=False): self._update_max_yx() self.stripspaces = 1 self.lastcmd = None + # Whether the last command typed a character into the lower-right cell. + self._corner_typed = False win.keypad(1) def _update_max_yx(self): @@ -88,6 +91,8 @@ def _insert_printable_char(self, ch): self._update_max_yx() (y, x) = self.win.getyx() backyx = None + corner_typed = self._corner_typed + self._corner_typed = False while True: if self.insert_mode: # The displaced cell, as a complexchar so addch() can rewrite it @@ -99,8 +104,28 @@ def _insert_printable_char(self, ch): # scrollable window). if isinstance(ch, int): self.win.insch(self._decode(ch), ch & curses.A_ATTRIBUTES) + elif (isinstance(ch, str) + and unicodedata.category(ch) in ('Mn', 'Me')): + if not corner_typed and x > 0: + # The cursor was moved here: attach the combining + # character to the preceding one, as elsewhere. + self.win.addch(ch) + break + # Attach it to the character typed into this cell. + cell = self.win.in_wch() + try: + cell = curses.complexchar(str(cell) + ch, + cell.attr, cell.pair) + except TypeError: + # The cell is full; drop it, as addch() does. + pass + else: + self.win.insch(cell) else: self.win.insch(ch) + if backyx is None: + # insch() leaves the cursor on the typed character. + self._corner_typed = True break # Pass the character as text: an integer is a byte, which addch() # takes as a code point on a wide build. @@ -108,8 +133,10 @@ def _insert_printable_char(self, ch): self.win.addch(self._decode(ch), ch & curses.A_ATTRIBUTES) else: self.win.addch(ch) - # In insert mode keep shifting cells right until a blank one. - if not self.insert_mode or not str(oldch).isprintable(): + # In insert mode keep shifting cells right until a blank one. A + # combining character joins the previous cell without taking one. + if (not self.insert_mode or not str(oldch).isprintable() + or self.win.getyx() == (y, x)): break ch = oldch (y, x) = self.win.getyx() @@ -135,6 +162,7 @@ def do_command(self, ch): elif self._printable_key(ch): self._insert_printable_char(ch) return 1 + self._corner_typed = False if ch == curses.ascii.SOH: # ^a self.win.move(y, 0) elif ch in (curses.ascii.STX,curses.KEY_LEFT, diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index 5db44ebedd2ee0a..1cc3805de395976 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -2723,6 +2723,46 @@ def test_textbox_combining(self): box.do_command(ch) self.assertEqual(box.gather(), text + ' ') + @requires_wide_build + def test_textbox_combining_fill_last_cell(self): + # A combining mark typed into the lower-right cell joins its base. + for text in [ + 'abe\u0301', # COMBINING ACUTE ACCENT + 'ab\u0e01\u0e31', # THAI CHARACTER MAI HAN-AKAT, combining() == 0 + 'abx\u20dd', # COMBINING ENCLOSING CIRCLE, combining() == 0 + ]: + with self.subTest(text=text): + if not self._encodable(text): + self.skipTest('the locale cannot encode %r' % text) + box, win = self._make_textbox(1, 3, stripspaces=False) + for ch in text: + box.do_command(ch) + self.assertEqual(box.gather(), text) + + @requires_wide_build + def test_textbox_combining_typed_over(self): + # A combining mark joins the character typed just before it. + text = 'e\u0301' + if not self._encodable(text): + self.skipTest('the locale cannot encode %r' % text) + for insert_mode, x, expected in [ + (False, 0, 'e\u0301bc'), + (False, 1, 'ae\u0301c'), + (False, 2, 'abe\u0301'), + (True, 0, 'e\u0301ab'), + (True, 1, 'ae\u0301b'), + (True, 2, 'abe\u0301'), + ]: + with self.subTest(insert_mode=insert_mode, x=x): + box, win = self._make_textbox(1, 3, stripspaces=False) + for ch in 'abc': + box.do_command(ch) + box.insert_mode = insert_mode + win.move(0, x) + for ch in text: + box.do_command(ch) + self.assertEqual(box.gather(), expected) + @requires_wide_build def test_textbox_double_width(self): # A double-width (East Asian) character occupies two cells. gather() diff --git a/Misc/NEWS.d/next/Library/2026-10-17-19-41-12.gh-issue-157210.o9hycSeJ.rst b/Misc/NEWS.d/next/Library/2026-10-17-19-41-12.gh-issue-157210.o9hycSeJ.rst new file mode 100644 index 000000000000000..0c399257b497fff --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-17-19-41-12.gh-issue-157210.o9hycSeJ.rst @@ -0,0 +1,5 @@ +Fix entering a combining character in the lower-right cell of +:class:`curses.textpad.Textbox`: +it no longer takes a cell of its own, +pushing its base character off the window. +In insert mode, a combining character no longer shifts the following text.