https://github.com/python/cpython/commit/b7790f140ab0633ff0207b3c07b0ed7c7d52a31a
commit: b7790f140ab0633ff0207b3c07b0ed7c7d52a31a
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-10-22T13:27:11+03:00
summary:

gh-157210: Attach a combining mark in the last curses Textbox cell (GH-159651)

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 <[email protected]>
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-10-17-19-41-12.gh-issue-157210.o9hycSeJ.rst
M Lib/curses/textpad.py
M Lib/test/test_curses.py

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 ec64ce52dd9e8c7..4d39e5014e654b5 100644
--- a/Lib/test/test_curses.py
+++ b/Lib/test/test_curses.py
@@ -2727,6 +2727,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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to