Skip to content

Commit 55fe0e6

Browse files
gh-152233: Add curses complexstr type and wide-character cell-array methods (GH-152262)
Add the immutable curses.complexstr type, an array of styled wide-character cells -- the string counterpart of complexchar. It is constructible from an iterable of cells (each a complexchar or a str) or from a string split into cells, with optional attr and pair applied to every cell. It is an immutable sequence (indexing yields a complexchar, slicing and concatenation yield a complexstr), is hashable, and str() returns its cells' text. Add the window method in_wchstr(), the wide-character counterpart of instr() and in_wstr() that keeps each cell's attributes and color pair instead of stripping them; it returns a complexstr. The methods addstr(), addnstr(), insstr() and insnstr() now also accept a complexstr, so a run read with in_wchstr() can be written back unchanged. The cells carry their own rendition, so combining one with an explicit attr raises TypeError. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8eb6fb0 commit 55fe0e6

6 files changed

Lines changed: 915 additions & 2 deletions

File tree

Doc/library/curses.rst

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -952,13 +952,21 @@ Window objects
952952
``(y, x)`` with attributes
953953
*attr*, overwriting anything previously on the display.
954954

955+
.. versionchanged:: next
956+
*str* may now also be a :class:`complexstr`; see :meth:`addstr`.
957+
955958

956959
.. method:: window.addstr(str[, attr])
957960
window.addstr(y, x, str[, attr])
958961

959962
Paint the character string *str* at ``(y, x)`` with attributes
960963
*attr*, overwriting anything previously on the display.
961964

965+
*str* may also be a :class:`complexstr`, in which case each cell carries its
966+
own attributes and color pair, so *attr* must not be given. A
967+
:class:`complexstr` obtained from :meth:`in_wchstr` is written back
968+
unchanged.
969+
962970
.. note::
963971

964972
* Writing outside the window, subwindow, or pad raises :exc:`curses.error`.
@@ -971,6 +979,9 @@ Window objects
971979
not calling :meth:`!addstr` with a *str* that has embedded newlines;
972980
instead, call :meth:`!addstr` separately for each line.
973981

982+
.. versionchanged:: next
983+
*str* may now also be a :class:`complexstr`, as described above.
984+
974985

975986
.. method:: window.attroff(attr)
976987

@@ -1428,6 +1439,9 @@ Window objects
14281439
cursor are shifted right, with the rightmost characters on the line being lost.
14291440
The cursor position does not change (after moving to *y*, *x*, if specified).
14301441

1442+
.. versionchanged:: next
1443+
*str* may now also be a :class:`complexstr`; see :meth:`insstr`.
1444+
14311445

14321446
.. method:: window.insstr(str[, attr])
14331447
window.insstr(y, x, str[, attr])
@@ -1437,6 +1451,12 @@ Window objects
14371451
shifted right, with the rightmost characters on the line being lost. The cursor
14381452
position does not change (after moving to *y*, *x*, if specified).
14391453

1454+
*str* may also be a :class:`complexstr`, in which case each cell carries its
1455+
own attributes and color pair, so *attr* must not be given.
1456+
1457+
.. versionchanged:: next
1458+
*str* may now also be a :class:`complexstr`, as described above.
1459+
14401460

14411461
.. method:: window.instr([n])
14421462
window.instr(y, x[, n])
@@ -1465,6 +1485,25 @@ Window objects
14651485
.. versionadded:: next
14661486

14671487

1488+
.. method:: window.in_wchstr([n])
1489+
window.in_wchstr(y, x[, n])
1490+
1491+
Return a :class:`complexstr` of the styled cells extracted from the window
1492+
starting at the current cursor position, or at *y*, *x* if specified, and
1493+
stopping at the end of the line. This is the variant of :meth:`instr` and
1494+
:meth:`in_wstr` that *keeps* each cell's attributes and color pair (those
1495+
methods strip the rendition). If *n* is specified, at most *n* cells are
1496+
returned. The maximum value for *n* is 2047.
1497+
1498+
The result can be written back unchanged with :meth:`addstr` (a read and a
1499+
re-write is a round-trip that preserves every cell's rendition).
1500+
1501+
This method is only available if Python was built against a wide-character
1502+
version of the underlying curses library.
1503+
1504+
.. versionadded:: next
1505+
1506+
14681507
.. method:: window.is_cleared()
14691508

14701509
Return the current value set by :meth:`clearok`.
@@ -1913,6 +1952,43 @@ Complex character objects
19131952
.. versionadded:: next
19141953

19151954

1955+
.. class:: complexstr(cells[, attr[, pair]])
1956+
1957+
A *complex character string* (or *complexstr*) is an immutable sequence of
1958+
styled wide-character cells -- the string counterpart of
1959+
:class:`complexchar` (as :class:`str` is to a single character).
1960+
1961+
If *cells* is a string, it is split into character cells (each a spacing
1962+
character optionally followed by combining characters), and *attr* (a
1963+
combination of the :ref:`WA_* attributes <curses-wa-constants>`) and *pair*
1964+
(a color pair number), if given, are applied to every cell.
1965+
1966+
Otherwise *cells* is an iterable whose items are themselves cells, each a
1967+
:class:`complexchar` or a string; each item then carries its own rendition,
1968+
and *attr* and *pair* must be omitted.
1969+
1970+
It is returned by :meth:`window.in_wchstr`, and accepted by
1971+
:meth:`window.addstr`, :meth:`~window.addnstr`, :meth:`~window.insstr` and
1972+
:meth:`~window.insnstr`, so a run read from a window can be written back
1973+
unchanged.
1974+
1975+
It behaves like an immutable sequence: ``len(s)`` is the number of cells,
1976+
``s[i]`` is the *i*-th cell as a :class:`complexchar`, slicing and
1977+
concatenation produce new :class:`!complexstr` instances, and iterating
1978+
yields the cells. :func:`str` returns the cells' text joined together, and
1979+
two complex character strings are equal when their cells all match. It is
1980+
hashable.
1981+
1982+
To build or edit a run of cells, use an ordinary :class:`list` of
1983+
:class:`complexchar` (or strings); a :class:`!complexstr` is the immutable
1984+
form returned by a read.
1985+
1986+
This type is only available if Python was built against a wide-character
1987+
version of the underlying curses library.
1988+
1989+
.. versionadded:: next
1990+
1991+
19161992
Constants
19171993
---------
19181994

Doc/whatsnew/3.16.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,14 @@ curses
148148
:class:`~curses.complexchar`.
149149
(Contributed by Serhiy Storchaka in :gh:`152233`.)
150150

151+
* Add the :class:`curses.complexstr` type, an immutable run of styled cells
152+
(the string counterpart of :class:`~curses.complexchar`), and the window
153+
method :meth:`~curses.window.in_wchstr` that returns one. The string-cell
154+
methods :meth:`~curses.window.addstr`, :meth:`~curses.window.addnstr`,
155+
:meth:`~curses.window.insstr` and :meth:`~curses.window.insnstr` now also
156+
accept a :class:`~curses.complexstr`.
157+
(Contributed by Serhiy Storchaka in :gh:`152233`.)
158+
151159
gzip
152160
----
153161

Lib/test/test_curses.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,145 @@ def test_getbkgrnd(self):
469469
self.assertEqual(str(cc), ' ')
470470
self.assertTrue(cc.attr & curses.A_BOLD)
471471

472+
@requires_curses_func('complexstr')
473+
def test_complexstr(self):
474+
# A complexstr is an immutable run of styled wide-character cells: the
475+
# string counterpart of complexchar (as str is to a single character).
476+
cc = curses.complexchar
477+
B = curses.A_BOLD
478+
# Built from an iterable whose items are complexchar or str cells.
479+
s = curses.complexstr([cc('A', B), 'b', cc('c')])
480+
self.assertEqual(len(s), 3)
481+
self.assertEqual(str(s), 'Abc')
482+
# Indexing yields a complexchar carrying the cell's rendition.
483+
self.assertIsInstance(s[0], curses.complexchar)
484+
self.assertEqual(str(s[0]), 'A')
485+
self.assertTrue(s[0].attr & B)
486+
self.assertEqual(s[-1], cc('c'))
487+
self.assertRaises(IndexError, lambda: s[3])
488+
# Iteration walks the cells.
489+
self.assertEqual([str(c) for c in s], ['A', 'b', 'c'])
490+
# Slicing and concatenation produce new complexstr instances.
491+
self.assertIsInstance(s[1:], curses.complexstr)
492+
self.assertEqual(str(s[1:]), 'bc')
493+
self.assertEqual(str(s[::-1]), 'cbA')
494+
self.assertEqual(str(s + curses.complexstr(['Z'])), 'AbcZ')
495+
# The empty complexstr.
496+
self.assertEqual(len(curses.complexstr([])), 0)
497+
self.assertEqual(str(curses.complexstr('')), '')
498+
# Equality and hashing compare the cells (text, attributes, pair).
499+
self.assertEqual(s, curses.complexstr([cc('A', B), 'b', cc('c')]))
500+
self.assertEqual(hash(s),
501+
hash(curses.complexstr([cc('A', B), 'b', cc('c')])))
502+
self.assertNotEqual(s, curses.complexstr([cc('A'), 'b', cc('c')]))
503+
self.assertNotEqual(s, curses.complexstr([cc('A', B), 'b']))
504+
# A spacing character optionally followed by combining characters.
505+
if self._encodable('é'):
506+
self.assertEqual(str(curses.complexstr(['é', 'x'])),
507+
'éx')
508+
# cells is positional-only.
509+
self.assertRaises(TypeError, lambda: curses.complexstr(cells=['x']))
510+
# Invalid arguments.
511+
self.assertRaises(TypeError, curses.complexstr, 5)
512+
self.assertRaises(TypeError, curses.complexstr, [65])
513+
self.assertRaises(ValueError, curses.complexstr, ['ab'])
514+
515+
# A string is split into character cells, grouping each base character
516+
# with the combining characters that follow it (not one cell per code
517+
# point), unlike a generic sequence whose items are each one cell.
518+
self.assertEqual(len(curses.complexstr('abc')), 3)
519+
self.assertEqual(str(curses.complexstr('abc')), 'abc')
520+
self.assertEqual(len(curses.complexstr('')), 0)
521+
base = 'é' # 'e' + combining acute: two code points, one cell
522+
if self._encodable(base):
523+
self.assertEqual(len(curses.complexstr(base)), 1)
524+
self.assertEqual(curses.complexstr(base)[0], cc(base))
525+
self.assertEqual(len(curses.complexstr('a' + base + 'b')), 3)
526+
# A combining character cannot begin a cell: one that leads the
527+
# string, or overflows a base's combining slots, has no base.
528+
self.assertRaises(ValueError, curses.complexstr, '\u0301')
529+
self.assertRaises(ValueError, curses.complexstr, 'e' + '\u0301' * 10)
530+
# A control character may stand alone but not carry combining marks.
531+
self.assertRaises(ValueError, curses.complexstr, '\n\u0301')
532+
# attr and pair apply to every cell of a string; pair is optional.
533+
styled = curses.complexstr('hi', B, 0)
534+
self.assertTrue(all(styled[i].attr & B for i in range(len(styled))))
535+
self.assertEqual(curses.complexstr('x', B)[0], cc('x', B))
536+
self.assertEqual(curses.complexstr('x', B, 0)[0], cc('x', B, 0))
537+
# attr and pair may also be passed by keyword.
538+
self.assertEqual(curses.complexstr('x', attr=B)[0], cc('x', B))
539+
self.assertEqual(curses.complexstr('x', attr=B, pair=0)[0], cc('x', B, 0))
540+
self.assertEqual(curses.complexstr('x', pair=0)[0], cc('x', 0, 0))
541+
# cells is positional-only.
542+
self.assertRaises(TypeError, lambda: curses.complexstr(cells='x'))
543+
self.assertRaises(ValueError, curses.complexstr, 'a', 0, -1)
544+
self.assertRaises(ValueError, lambda: curses.complexstr('a', pair=-1))
545+
# For a non-string, giving attr/pair at all is an error (the cells
546+
# carry their own rendition) -- even attr=0.
547+
self.assertRaises(TypeError, curses.complexstr, [cc('A')], B)
548+
self.assertRaises(TypeError, curses.complexstr, [cc('A')], 0)
549+
self.assertRaises(TypeError, curses.complexstr, ['A'], 0, 0)
550+
self.assertRaises(TypeError,
551+
lambda: curses.complexstr([cc('A')], attr=B))
552+
self.assertRaises(TypeError,
553+
lambda: curses.complexstr(['A'], pair=0))
554+
555+
@requires_curses_window_meth('in_wchstr')
556+
def test_in_wchstr(self):
557+
# in_wchstr() returns a complexstr -- the styled-cell counterpart of
558+
# instr() (bytes) and in_wstr() (str), which both strip the rendition.
559+
stdscr = self.stdscr
560+
cc = curses.complexchar
561+
B = curses.A_BOLD
562+
s = curses.complexstr([cc('A', B), cc('b'), cc('C', B)])
563+
stdscr.addstr(0, 0, s)
564+
r = stdscr.in_wchstr(0, 0, 3)
565+
self.assertIsInstance(r, curses.complexstr)
566+
# A read followed by a re-write is an exact round-trip.
567+
self.assertEqual(r, s)
568+
self.assertEqual(str(r), 'AbC')
569+
self.assertTrue(r[0].attr & B)
570+
self.assertFalse(r[1].attr & B)
571+
# The count is optional and reads to the end of the line by default.
572+
stdscr.move(0, 0)
573+
self.assertEqual(str(stdscr.in_wchstr())[:3], 'AbC')
574+
575+
@requires_curses_window_meth('in_wchstr')
576+
def test_complexstr_in_write_methods(self):
577+
# addstr/addnstr/insstr/insnstr also accept a complexstr, written via
578+
# the wide-character functions; a plain str keeps its current meaning.
579+
stdscr = self.stdscr
580+
cc = curses.complexchar
581+
B = curses.A_BOLD
582+
s = curses.complexstr([cc('A', B), cc('b'), cc('C', B)])
583+
# addstr with a complexstr round-trips.
584+
stdscr.addstr(0, 0, s)
585+
self.assertEqual(stdscr.in_wchstr(0, 0, 3), s)
586+
# addnstr writes at most n cells.
587+
stdscr.addstr(2, 0, '....')
588+
stdscr.addnstr(2, 0, s, 2)
589+
self.assertEqual(str(stdscr.in_wchstr(2, 0, 4)), 'Ab..')
590+
# insstr inserts the cells in order.
591+
stdscr.move(3, 0)
592+
stdscr.addstr('END')
593+
stdscr.insstr(3, 0, curses.complexstr([cc('P'), cc('Q')]))
594+
self.assertEqual(str(stdscr.in_wchstr(3, 0, 5)), 'PQEND')
595+
# insnstr inserts at most n cells.
596+
stdscr.move(4, 0)
597+
stdscr.addstr('END')
598+
stdscr.insnstr(4, 0, curses.complexstr(['1', '2', '3']), 2)
599+
self.assertEqual(str(stdscr.in_wchstr(4, 0, 5)), '12END')
600+
# An empty run is accepted (and still honours the move).
601+
stdscr.addstr(5, 0, curses.complexstr([]))
602+
stdscr.insstr(5, 0, curses.complexstr([]))
603+
# Cells carry their own rendition, so an explicit attr is rejected.
604+
self.assertRaises(TypeError, stdscr.addstr, s, B)
605+
self.assertRaises(TypeError, stdscr.addnstr, s, 2, B)
606+
self.assertRaises(TypeError, stdscr.insstr, s, B)
607+
self.assertRaises(TypeError, stdscr.insnstr, s, 2, B)
608+
# A bare sequence of cells is not accepted; build a complexstr first.
609+
self.assertRaises(TypeError, stdscr.addstr, [cc('A'), 'b'])
610+
self.assertRaises(TypeError, stdscr.insstr, [cc('A'), 'b'])
472611

473612
def test_output_character(self):
474613
stdscr = self.stdscr
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Add the :class:`curses.complexstr` type, an immutable string of styled
2+
wide-character cells (the counterpart of :class:`curses.complexchar`), and the
3+
:mod:`curses` window method :meth:`~curses.window.in_wchstr` that returns one.
4+
The string-cell methods :meth:`~curses.window.addstr`,
5+
:meth:`~curses.window.addnstr`, :meth:`~curses.window.insstr` and
6+
:meth:`~curses.window.insnstr` now also accept a :class:`~curses.complexstr`.

0 commit comments

Comments
 (0)