Skip to content

Commit b7790f1

Browse files
serhiy-storchakafedonmanclaude
authored
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 <hi@fedonman.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4f5a67a commit b7790f1

3 files changed

Lines changed: 75 additions & 2 deletions

File tree

‎Lib/curses/textpad.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import curses
44
import curses.ascii
5+
import unicodedata
56

67
def rectangle(win, uly, ulx, lry, lrx):
78
"""Draw a rectangle with corners at the provided upper-left
@@ -49,6 +50,8 @@ def __init__(self, win, insert_mode=False):
4950
self._update_max_yx()
5051
self.stripspaces = 1
5152
self.lastcmd = None
53+
# Whether the last command typed a character into the lower-right cell.
54+
self._corner_typed = False
5255
win.keypad(1)
5356

5457
def _update_max_yx(self):
@@ -88,6 +91,8 @@ def _insert_printable_char(self, ch):
8891
self._update_max_yx()
8992
(y, x) = self.win.getyx()
9093
backyx = None
94+
corner_typed = self._corner_typed
95+
self._corner_typed = False
9196
while True:
9297
if self.insert_mode:
9398
# The displaced cell, as a complexchar so addch() can rewrite it
@@ -99,17 +104,39 @@ def _insert_printable_char(self, ch):
99104
# scrollable window).
100105
if isinstance(ch, int):
101106
self.win.insch(self._decode(ch), ch & curses.A_ATTRIBUTES)
107+
elif (isinstance(ch, str)
108+
and unicodedata.category(ch) in ('Mn', 'Me')):
109+
if not corner_typed and x > 0:
110+
# The cursor was moved here: attach the combining
111+
# character to the preceding one, as elsewhere.
112+
self.win.addch(ch)
113+
break
114+
# Attach it to the character typed into this cell.
115+
cell = self.win.in_wch()
116+
try:
117+
cell = curses.complexchar(str(cell) + ch,
118+
cell.attr, cell.pair)
119+
except TypeError:
120+
# The cell is full; drop it, as addch() does.
121+
pass
122+
else:
123+
self.win.insch(cell)
102124
else:
103125
self.win.insch(ch)
126+
if backyx is None:
127+
# insch() leaves the cursor on the typed character.
128+
self._corner_typed = True
104129
break
105130
# Pass the character as text: an integer is a byte, which addch()
106131
# takes as a code point on a wide build.
107132
if isinstance(ch, int):
108133
self.win.addch(self._decode(ch), ch & curses.A_ATTRIBUTES)
109134
else:
110135
self.win.addch(ch)
111-
# In insert mode keep shifting cells right until a blank one.
112-
if not self.insert_mode or not str(oldch).isprintable():
136+
# In insert mode keep shifting cells right until a blank one. A
137+
# combining character joins the previous cell without taking one.
138+
if (not self.insert_mode or not str(oldch).isprintable()
139+
or self.win.getyx() == (y, x)):
113140
break
114141
ch = oldch
115142
(y, x) = self.win.getyx()
@@ -135,6 +162,7 @@ def do_command(self, ch):
135162
elif self._printable_key(ch):
136163
self._insert_printable_char(ch)
137164
return 1
165+
self._corner_typed = False
138166
if ch == curses.ascii.SOH: # ^a
139167
self.win.move(y, 0)
140168
elif ch in (curses.ascii.STX,curses.KEY_LEFT,

‎Lib/test/test_curses.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2727,6 +2727,46 @@ def test_textbox_combining(self):
27272727
box.do_command(ch)
27282728
self.assertEqual(box.gather(), text + ' ')
27292729

2730+
@requires_wide_build
2731+
def test_textbox_combining_fill_last_cell(self):
2732+
# A combining mark typed into the lower-right cell joins its base.
2733+
for text in [
2734+
'abe\u0301', # COMBINING ACUTE ACCENT
2735+
'ab\u0e01\u0e31', # THAI CHARACTER MAI HAN-AKAT, combining() == 0
2736+
'abx\u20dd', # COMBINING ENCLOSING CIRCLE, combining() == 0
2737+
]:
2738+
with self.subTest(text=text):
2739+
if not self._encodable(text):
2740+
self.skipTest('the locale cannot encode %r' % text)
2741+
box, win = self._make_textbox(1, 3, stripspaces=False)
2742+
for ch in text:
2743+
box.do_command(ch)
2744+
self.assertEqual(box.gather(), text)
2745+
2746+
@requires_wide_build
2747+
def test_textbox_combining_typed_over(self):
2748+
# A combining mark joins the character typed just before it.
2749+
text = 'e\u0301'
2750+
if not self._encodable(text):
2751+
self.skipTest('the locale cannot encode %r' % text)
2752+
for insert_mode, x, expected in [
2753+
(False, 0, 'e\u0301bc'),
2754+
(False, 1, 'ae\u0301c'),
2755+
(False, 2, 'abe\u0301'),
2756+
(True, 0, 'e\u0301ab'),
2757+
(True, 1, 'ae\u0301b'),
2758+
(True, 2, 'abe\u0301'),
2759+
]:
2760+
with self.subTest(insert_mode=insert_mode, x=x):
2761+
box, win = self._make_textbox(1, 3, stripspaces=False)
2762+
for ch in 'abc':
2763+
box.do_command(ch)
2764+
box.insert_mode = insert_mode
2765+
win.move(0, x)
2766+
for ch in text:
2767+
box.do_command(ch)
2768+
self.assertEqual(box.gather(), expected)
2769+
27302770
@requires_wide_build
27312771
def test_textbox_double_width(self):
27322772
# A double-width (East Asian) character occupies two cells. gather()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Fix entering a combining character in the lower-right cell of
2+
:class:`curses.textpad.Textbox`:
3+
it no longer takes a cell of its own,
4+
pushing its base character off the window.
5+
In insert mode, a combining character no longer shifts the following text.

0 commit comments

Comments
 (0)