From 55fe0e64f3a50e5f292198192a07a3f0743df362 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 26 Jun 2026 14:37:30 +0300 Subject: [PATCH] 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 --- Doc/library/curses.rst | 76 +++ Doc/whatsnew/3.16.rst | 8 + Lib/test/test_curses.py | 139 ++++ ...-06-26-11-20-00.gh-issue-152233.Kp7mQ2.rst | 6 + Modules/_cursesmodule.c | 595 +++++++++++++++++- Modules/clinic/_cursesmodule.c.h | 93 ++- 6 files changed, 915 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-26-11-20-00.gh-issue-152233.Kp7mQ2.rst diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 945be3b51998..69af3b94d72d 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -952,6 +952,9 @@ Window objects ``(y, x)`` with attributes *attr*, overwriting anything previously on the display. + .. versionchanged:: next + *str* may now also be a :class:`complexstr`; see :meth:`addstr`. + .. method:: window.addstr(str[, attr]) window.addstr(y, x, str[, attr]) @@ -959,6 +962,11 @@ Window objects Paint the character string *str* at ``(y, x)`` with attributes *attr*, overwriting anything previously on the display. + *str* may also be a :class:`complexstr`, in which case each cell carries its + own attributes and color pair, so *attr* must not be given. A + :class:`complexstr` obtained from :meth:`in_wchstr` is written back + unchanged. + .. note:: * Writing outside the window, subwindow, or pad raises :exc:`curses.error`. @@ -971,6 +979,9 @@ Window objects not calling :meth:`!addstr` with a *str* that has embedded newlines; instead, call :meth:`!addstr` separately for each line. + .. versionchanged:: next + *str* may now also be a :class:`complexstr`, as described above. + .. method:: window.attroff(attr) @@ -1428,6 +1439,9 @@ Window objects cursor are shifted right, with the rightmost characters on the line being lost. The cursor position does not change (after moving to *y*, *x*, if specified). + .. versionchanged:: next + *str* may now also be a :class:`complexstr`; see :meth:`insstr`. + .. method:: window.insstr(str[, attr]) window.insstr(y, x, str[, attr]) @@ -1437,6 +1451,12 @@ Window objects shifted right, with the rightmost characters on the line being lost. The cursor position does not change (after moving to *y*, *x*, if specified). + *str* may also be a :class:`complexstr`, in which case each cell carries its + own attributes and color pair, so *attr* must not be given. + + .. versionchanged:: next + *str* may now also be a :class:`complexstr`, as described above. + .. method:: window.instr([n]) window.instr(y, x[, n]) @@ -1465,6 +1485,25 @@ Window objects .. versionadded:: next +.. method:: window.in_wchstr([n]) + window.in_wchstr(y, x[, n]) + + Return a :class:`complexstr` of the styled cells extracted from the window + starting at the current cursor position, or at *y*, *x* if specified, and + stopping at the end of the line. This is the variant of :meth:`instr` and + :meth:`in_wstr` that *keeps* each cell's attributes and color pair (those + methods strip the rendition). If *n* is specified, at most *n* cells are + returned. The maximum value for *n* is 2047. + + The result can be written back unchanged with :meth:`addstr` (a read and a + re-write is a round-trip that preserves every cell's rendition). + + This method is only available if Python was built against a wide-character + version of the underlying curses library. + + .. versionadded:: next + + .. method:: window.is_cleared() Return the current value set by :meth:`clearok`. @@ -1913,6 +1952,43 @@ Complex character objects .. versionadded:: next +.. class:: complexstr(cells[, attr[, pair]]) + + A *complex character string* (or *complexstr*) is an immutable sequence of + styled wide-character cells -- the string counterpart of + :class:`complexchar` (as :class:`str` is to a single character). + + If *cells* is a string, it is split into character cells (each a spacing + character optionally followed by combining characters), and *attr* (a + combination of the :ref:`WA_* attributes `) and *pair* + (a color pair number), if given, are applied to every cell. + + Otherwise *cells* is an iterable whose items are themselves cells, each a + :class:`complexchar` or a string; each item then carries its own rendition, + and *attr* and *pair* must be omitted. + + It is returned by :meth:`window.in_wchstr`, and accepted by + :meth:`window.addstr`, :meth:`~window.addnstr`, :meth:`~window.insstr` and + :meth:`~window.insnstr`, so a run read from a window can be written back + unchanged. + + It behaves like an immutable sequence: ``len(s)`` is the number of cells, + ``s[i]`` is the *i*-th cell as a :class:`complexchar`, slicing and + concatenation produce new :class:`!complexstr` instances, and iterating + yields the cells. :func:`str` returns the cells' text joined together, and + two complex character strings are equal when their cells all match. It is + hashable. + + To build or edit a run of cells, use an ordinary :class:`list` of + :class:`complexchar` (or strings); a :class:`!complexstr` is the immutable + form returned by a read. + + This type is only available if Python was built against a wide-character + version of the underlying curses library. + + .. versionadded:: next + + Constants --------- diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index c0b6a650b7b8..80f13e4d759d 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -148,6 +148,14 @@ curses :class:`~curses.complexchar`. (Contributed by Serhiy Storchaka in :gh:`152233`.) +* Add the :class:`curses.complexstr` type, an immutable run of styled cells + (the string counterpart of :class:`~curses.complexchar`), and the window + method :meth:`~curses.window.in_wchstr` that returns one. The string-cell + methods :meth:`~curses.window.addstr`, :meth:`~curses.window.addnstr`, + :meth:`~curses.window.insstr` and :meth:`~curses.window.insnstr` now also + accept a :class:`~curses.complexstr`. + (Contributed by Serhiy Storchaka in :gh:`152233`.) + gzip ---- diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index bc3cc5cb3835..27a64532b21f 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -469,6 +469,145 @@ class TestCurses(unittest.TestCase): self.assertEqual(str(cc), ' ') self.assertTrue(cc.attr & curses.A_BOLD) + @requires_curses_func('complexstr') + def test_complexstr(self): + # A complexstr is an immutable run of styled wide-character cells: the + # string counterpart of complexchar (as str is to a single character). + cc = curses.complexchar + B = curses.A_BOLD + # Built from an iterable whose items are complexchar or str cells. + s = curses.complexstr([cc('A', B), 'b', cc('c')]) + self.assertEqual(len(s), 3) + self.assertEqual(str(s), 'Abc') + # Indexing yields a complexchar carrying the cell's rendition. + self.assertIsInstance(s[0], curses.complexchar) + self.assertEqual(str(s[0]), 'A') + self.assertTrue(s[0].attr & B) + self.assertEqual(s[-1], cc('c')) + self.assertRaises(IndexError, lambda: s[3]) + # Iteration walks the cells. + self.assertEqual([str(c) for c in s], ['A', 'b', 'c']) + # Slicing and concatenation produce new complexstr instances. + self.assertIsInstance(s[1:], curses.complexstr) + self.assertEqual(str(s[1:]), 'bc') + self.assertEqual(str(s[::-1]), 'cbA') + self.assertEqual(str(s + curses.complexstr(['Z'])), 'AbcZ') + # The empty complexstr. + self.assertEqual(len(curses.complexstr([])), 0) + self.assertEqual(str(curses.complexstr('')), '') + # Equality and hashing compare the cells (text, attributes, pair). + self.assertEqual(s, curses.complexstr([cc('A', B), 'b', cc('c')])) + self.assertEqual(hash(s), + hash(curses.complexstr([cc('A', B), 'b', cc('c')]))) + self.assertNotEqual(s, curses.complexstr([cc('A'), 'b', cc('c')])) + self.assertNotEqual(s, curses.complexstr([cc('A', B), 'b'])) + # A spacing character optionally followed by combining characters. + if self._encodable('é'): + self.assertEqual(str(curses.complexstr(['é', 'x'])), + 'éx') + # cells is positional-only. + self.assertRaises(TypeError, lambda: curses.complexstr(cells=['x'])) + # Invalid arguments. + self.assertRaises(TypeError, curses.complexstr, 5) + self.assertRaises(TypeError, curses.complexstr, [65]) + self.assertRaises(ValueError, curses.complexstr, ['ab']) + + # A string is split into character cells, grouping each base character + # with the combining characters that follow it (not one cell per code + # point), unlike a generic sequence whose items are each one cell. + self.assertEqual(len(curses.complexstr('abc')), 3) + self.assertEqual(str(curses.complexstr('abc')), 'abc') + self.assertEqual(len(curses.complexstr('')), 0) + base = 'é' # 'e' + combining acute: two code points, one cell + if self._encodable(base): + self.assertEqual(len(curses.complexstr(base)), 1) + self.assertEqual(curses.complexstr(base)[0], cc(base)) + self.assertEqual(len(curses.complexstr('a' + base + 'b')), 3) + # A combining character cannot begin a cell: one that leads the + # string, or overflows a base's combining slots, has no base. + self.assertRaises(ValueError, curses.complexstr, '\u0301') + self.assertRaises(ValueError, curses.complexstr, 'e' + '\u0301' * 10) + # A control character may stand alone but not carry combining marks. + self.assertRaises(ValueError, curses.complexstr, '\n\u0301') + # attr and pair apply to every cell of a string; pair is optional. + styled = curses.complexstr('hi', B, 0) + self.assertTrue(all(styled[i].attr & B for i in range(len(styled)))) + self.assertEqual(curses.complexstr('x', B)[0], cc('x', B)) + self.assertEqual(curses.complexstr('x', B, 0)[0], cc('x', B, 0)) + # attr and pair may also be passed by keyword. + self.assertEqual(curses.complexstr('x', attr=B)[0], cc('x', B)) + self.assertEqual(curses.complexstr('x', attr=B, pair=0)[0], cc('x', B, 0)) + self.assertEqual(curses.complexstr('x', pair=0)[0], cc('x', 0, 0)) + # cells is positional-only. + self.assertRaises(TypeError, lambda: curses.complexstr(cells='x')) + self.assertRaises(ValueError, curses.complexstr, 'a', 0, -1) + self.assertRaises(ValueError, lambda: curses.complexstr('a', pair=-1)) + # For a non-string, giving attr/pair at all is an error (the cells + # carry their own rendition) -- even attr=0. + self.assertRaises(TypeError, curses.complexstr, [cc('A')], B) + self.assertRaises(TypeError, curses.complexstr, [cc('A')], 0) + self.assertRaises(TypeError, curses.complexstr, ['A'], 0, 0) + self.assertRaises(TypeError, + lambda: curses.complexstr([cc('A')], attr=B)) + self.assertRaises(TypeError, + lambda: curses.complexstr(['A'], pair=0)) + + @requires_curses_window_meth('in_wchstr') + def test_in_wchstr(self): + # in_wchstr() returns a complexstr -- the styled-cell counterpart of + # instr() (bytes) and in_wstr() (str), which both strip the rendition. + stdscr = self.stdscr + cc = curses.complexchar + B = curses.A_BOLD + s = curses.complexstr([cc('A', B), cc('b'), cc('C', B)]) + stdscr.addstr(0, 0, s) + r = stdscr.in_wchstr(0, 0, 3) + self.assertIsInstance(r, curses.complexstr) + # A read followed by a re-write is an exact round-trip. + self.assertEqual(r, s) + self.assertEqual(str(r), 'AbC') + self.assertTrue(r[0].attr & B) + self.assertFalse(r[1].attr & B) + # The count is optional and reads to the end of the line by default. + stdscr.move(0, 0) + self.assertEqual(str(stdscr.in_wchstr())[:3], 'AbC') + + @requires_curses_window_meth('in_wchstr') + def test_complexstr_in_write_methods(self): + # addstr/addnstr/insstr/insnstr also accept a complexstr, written via + # the wide-character functions; a plain str keeps its current meaning. + stdscr = self.stdscr + cc = curses.complexchar + B = curses.A_BOLD + s = curses.complexstr([cc('A', B), cc('b'), cc('C', B)]) + # addstr with a complexstr round-trips. + stdscr.addstr(0, 0, s) + self.assertEqual(stdscr.in_wchstr(0, 0, 3), s) + # addnstr writes at most n cells. + stdscr.addstr(2, 0, '....') + stdscr.addnstr(2, 0, s, 2) + self.assertEqual(str(stdscr.in_wchstr(2, 0, 4)), 'Ab..') + # insstr inserts the cells in order. + stdscr.move(3, 0) + stdscr.addstr('END') + stdscr.insstr(3, 0, curses.complexstr([cc('P'), cc('Q')])) + self.assertEqual(str(stdscr.in_wchstr(3, 0, 5)), 'PQEND') + # insnstr inserts at most n cells. + stdscr.move(4, 0) + stdscr.addstr('END') + stdscr.insnstr(4, 0, curses.complexstr(['1', '2', '3']), 2) + self.assertEqual(str(stdscr.in_wchstr(4, 0, 5)), '12END') + # An empty run is accepted (and still honours the move). + stdscr.addstr(5, 0, curses.complexstr([])) + stdscr.insstr(5, 0, curses.complexstr([])) + # Cells carry their own rendition, so an explicit attr is rejected. + self.assertRaises(TypeError, stdscr.addstr, s, B) + self.assertRaises(TypeError, stdscr.addnstr, s, 2, B) + self.assertRaises(TypeError, stdscr.insstr, s, B) + self.assertRaises(TypeError, stdscr.insnstr, s, 2, B) + # A bare sequence of cells is not accepted; build a complexstr first. + self.assertRaises(TypeError, stdscr.addstr, [cc('A'), 'b']) + self.assertRaises(TypeError, stdscr.insstr, [cc('A'), 'b']) def test_output_character(self): stdscr = self.stdscr diff --git a/Misc/NEWS.d/next/Library/2026-06-26-11-20-00.gh-issue-152233.Kp7mQ2.rst b/Misc/NEWS.d/next/Library/2026-06-26-11-20-00.gh-issue-152233.Kp7mQ2.rst new file mode 100644 index 000000000000..da9cf22a0dd5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-26-11-20-00.gh-issue-152233.Kp7mQ2.rst @@ -0,0 +1,6 @@ +Add the :class:`curses.complexstr` type, an immutable string of styled +wide-character cells (the counterpart of :class:`curses.complexchar`), and the +:mod:`curses` window method :meth:`~curses.window.in_wchstr` that returns one. +The string-cell methods :meth:`~curses.window.addstr`, +:meth:`~curses.window.addnstr`, :meth:`~curses.window.insstr` and +:meth:`~curses.window.insnstr` now also accept a :class:`~curses.complexstr`. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 96e1fdc3bfe5..1274f291e15f 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -109,6 +109,7 @@ static const char PyCursesVersion[] = "2.2"; #include "pycore_long.h" // _PyLong_GetZero() #include "pycore_structseq.h" // _PyStructSequence_NewType() #include "pycore_fileutils.h" // _Py_dup(), _Py_set_inheritable() +#include "pycore_tuple.h" // _PyTuple_HASH_XXPRIME_1 #ifdef __hpux #define STRICT_SYSV_CURSES @@ -167,6 +168,7 @@ typedef struct { PyTypeObject *screen_type; // _curses.screen #ifdef HAVE_NCURSESW PyTypeObject *complexchar_type; // _curses.complexchar + PyTypeObject *complexstr_type; // _curses.complexstr #endif PyObject *topscreen; // owned ref to the current screen object, // or NULL for the initscr() screen @@ -202,8 +204,9 @@ module _curses class _curses.window "PyCursesWindowObject *" "clinic_state()->window_type" class _curses.screen "PyCursesScreenObject *" "clinic_state()->screen_type" class _curses.complexchar "PyCursesComplexCharObject *" "clinic_state()->complexchar_type" +class _curses.complexstr "PyCursesComplexStrObject *" "get_cursesmodule_state_by_cls(type)->complexstr_type" [clinic start generated code]*/ -/*[clinic end generated code: output=da39a3ee5e6b4b0d input=211a02287a60aed0]*/ +/*[clinic end generated code: output=da39a3ee5e6b4b0d input=e9439fe0a704a26e]*/ /* Indicate whether the module has already been loaded or not. */ static int curses_module_loaded = 0; @@ -669,6 +672,17 @@ typedef struct { #define _PyCursesComplexCharObject_CAST(op) ((PyCursesComplexCharObject *)(op)) +/* An immutable packed array of cchar_t cells -- the "complex character + string" counterpart of complexchar (as str is to a single character). + It owns the contiguous buffer that win_wchnstr() fills directly, so a read + and a re-write is a zero-copy round-trip. */ +typedef struct { + PyObject_VAR_HEAD + cchar_t cells[1]; // ob_size cells, stored inline (variable-size object) +} PyCursesComplexStrObject; + +#define _PyCursesComplexStrObject_CAST(op) ((PyCursesComplexStrObject *)(op)) + /* Build a single character cell from obj. Return 1 and store a chtype in *pch for an int or bytes, 2 and store a @@ -1117,6 +1131,430 @@ static PyGetSetDef complexchar_getsets[] = { {NULL} }; +/* -------------------------------------------------------------*/ +/* Complex character strings (immutable arrays of styled cells) */ +/* -------------------------------------------------------------*/ + +/* Pack a single Python cell -- a complexchar (used as is) or a str (a spacing + character optionally followed by combining characters, with no attributes + and color pair 0) -- into *out. Return 0 on success, -1 with an exception + set otherwise. */ +static int +curses_pack_cell(cursesmodule_state *state, PyObject *item, cchar_t *out) +{ + if (Py_IS_TYPE(item, state->complexchar_type)) { + *out = _PyCursesComplexCharObject_CAST(item)->cval; + return 0; + } + if (PyUnicode_Check(item)) { + wchar_t wstr[CCHARW_MAX + 1]; + if (PyCurses_ConvertToWideCell(item, wstr) < 0) { + return -1; + } + if (curses_setcchar(out, wstr, A_NORMAL, 0) == ERR) { + PyErr_SetString(state->error, "setcchar() returned ERR"); + return -1; + } + return 0; + } + PyErr_Format(PyExc_TypeError, + "complexstr cell must be a complexchar or a str, not %T", + item); + return -1; +} + +/* Wrap a buffer of len cells in a new complexstr, copying them in. tp_alloc + sizes the variable-size object for len cells and sets ob_size. */ +static PyObject * +PyCursesComplexStr_New(cursesmodule_state *state, const cchar_t *cells, + Py_ssize_t len) +{ + PyTypeObject *type = state->complexstr_type; + PyObject *res = type->tp_alloc(type, len); + if (res != NULL && len > 0) { + memcpy(_PyCursesComplexStrObject_CAST(res)->cells, cells, + (size_t)len * sizeof(cchar_t)); + } + return res; +} + +/* Build a complexstr from a string, grouping each base character with its + trailing combining characters into one cell (so "é" is one cell, not two). + A string needs this separate path because a generic sequence is packed one + cell per item, which would not keep combining marks with their base. */ +static PyObject * +complexstr_from_string(cursesmodule_state *state, PyObject *str, + attr_t attr, int pair) +{ + Py_ssize_t n; + wchar_t *wbuf = PyUnicode_AsWideCharString(str, &n); + if (wbuf == NULL) { + return NULL; + } + cchar_t *cells = n > 0 ? PyMem_New(cchar_t, n) : NULL; + if (n > 0 && cells == NULL) { + PyMem_Free(wbuf); + return PyErr_NoMemory(); + } + Py_ssize_t count = 0; + for (Py_ssize_t i = 0; i < n; ) { + wchar_t cell[CCHARW_MAX + 1]; + Py_ssize_t k = 0; + cell[k++] = wbuf[i++]; + while (i < n && k < CCHARW_MAX && wcwidth(wbuf[i]) == 0) { + cell[k++] = wbuf[i++]; + } + cell[k] = L'\0'; + /* A cell's base must be a spacing character. A combining character + (wcwidth 0) has no base to attach to, so it cannot start a cell. A + control character (wcwidth < 0) may stand alone but cannot carry + combining marks. */ + int width = wcwidth(cell[0]); + if (width == 0 || (k > 1 && width < 0)) { + PyErr_Format(PyExc_ValueError, + "a character cell must be a single spacing character " + "optionally followed by up to %d combining characters", + (int)(CCHARW_MAX - 1)); + PyMem_Free(cells); + PyMem_Free(wbuf); + return NULL; + } + if (curses_setcchar(&cells[count], cell, attr, pair) == ERR) { + if (!PyErr_Occurred()) { + PyErr_SetString(state->error, "setcchar() returned ERR"); + } + PyMem_Free(cells); + PyMem_Free(wbuf); + return NULL; + } + count++; + } + PyObject *res = PyCursesComplexStr_New(state, cells, count); + PyMem_Free(cells); + PyMem_Free(wbuf); + return res; +} + +/*[clinic input] +@classmethod +_curses.complexstr.__new__ as complexstr_new + + cells: object + An iterable of cells, each a complexchar or a str. + / + attr: object = NULL + Attributes applied to every cell (only with a string). + pair: object = NULL + Color pair applied to every cell (only with a string). + +An immutable string of styled wide-character cells. + +It is the counterpart of complexchar for a run of cells, and the type +returned by window.in_wchstr(). Each cell is a complexchar or a str (a +spacing character optionally followed by combining characters). + +When cells is a string it is split into character cells, and attr and +pair (if given) style every cell. Otherwise each item carries its own +rendition, and attr and pair must be omitted. +[clinic start generated code]*/ + +static PyObject * +complexstr_new_impl(PyTypeObject *type, PyObject *cells, PyObject *attr, + PyObject *pair) +/*[clinic end generated code: output=ef8a53143d35a32a input=9b75aee973cc6565]*/ +{ + cursesmodule_state *state = get_cursesmodule_state_by_cls(type); + /* A string is split into cells (grouping combining characters), not + iterated as one cell per code point; attr/pair then style every cell. */ + if (PyUnicode_Check(cells)) { + attr_t cattr = A_NORMAL; + int cpair = 0; + if (attr != NULL && !attr_converter(attr, &cattr)) { + return NULL; + } + if (pair != NULL) { + cpair = PyLong_AsInt(pair); + if (cpair == -1 && PyErr_Occurred()) { + return NULL; + } + if (cpair < 0) { + PyErr_SetString(PyExc_ValueError, "color pair is less than 0"); + return NULL; + } + } + return complexstr_from_string(state, cells, cattr, cpair); + } + /* For any other sequence each item carries its own rendition, so attr and + pair cannot be given. */ + if (attr != NULL || pair != NULL) { + PyErr_SetString(PyExc_TypeError, + "attr and pair can only be given with a string"); + return NULL; + } + PyObject *seq = PySequence_Fast(cells, + "complexstr() argument must be an iterable"); + if (seq == NULL) { + return NULL; + } + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + PyObject *res = type->tp_alloc(type, n); + if (res == NULL) { + Py_DECREF(seq); + return NULL; + } + cchar_t *out = _PyCursesComplexStrObject_CAST(res)->cells; + for (Py_ssize_t i = 0; i < n; i++) { + PyObject *item = PySequence_Fast_GET_ITEM(seq, i); // borrowed + if (curses_pack_cell(state, item, &out[i]) < 0) { + Py_DECREF(res); + Py_DECREF(seq); + return NULL; + } + } + Py_DECREF(seq); + return res; +} + +static void +complexstr_dealloc(PyObject *self) +{ + PyTypeObject *tp = Py_TYPE(self); + tp->tp_free(self); + Py_DECREF(tp); +} + +static Py_ssize_t +complexstr_length(PyObject *self) +{ + return Py_SIZE(self); +} + +/* Wrap cell i (no bounds check) in a new complexchar. */ +static PyObject * +complexstr_getcell(PyObject *self, Py_ssize_t i) +{ + cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(self)); + cchar_t *cells = _PyCursesComplexStrObject_CAST(self)->cells; + return PyCursesComplexChar_New(state, &cells[i]); +} + +static PyObject * +complexstr_item(PyObject *self, Py_ssize_t i) +{ + if (i < 0 || i >= Py_SIZE(self)) { + PyErr_SetString(PyExc_IndexError, "complexstr index out of range"); + return NULL; + } + return complexstr_getcell(self, i); +} + +static PyObject * +complexstr_subscript(PyObject *self, PyObject *key) +{ + PyCursesComplexStrObject *s = _PyCursesComplexStrObject_CAST(self); + if (PyIndex_Check(key)) { + Py_ssize_t i = PyNumber_AsSsize_t(key, PyExc_IndexError); + if (i == -1 && PyErr_Occurred()) { + return NULL; + } + if (i < 0) { + i += Py_SIZE(s); + } + return complexstr_item(self, i); + } + if (PySlice_Check(key)) { + Py_ssize_t start, stop, step, slicelen; + if (PySlice_GetIndicesEx(key, Py_SIZE(s), &start, &stop, &step, + &slicelen) < 0) + { + return NULL; + } + cursesmodule_state *state = + get_cursesmodule_state_by_cls(Py_TYPE(self)); + PyTypeObject *type = state->complexstr_type; + PyObject *res = type->tp_alloc(type, slicelen); + if (res == NULL) { + return NULL; + } + cchar_t *out = _PyCursesComplexStrObject_CAST(res)->cells; + for (Py_ssize_t i = 0, idx = start; i < slicelen; i++, idx += step) { + out[i] = s->cells[idx]; + } + return res; + } + PyErr_Format(PyExc_TypeError, + "complexstr indices must be integers or slices, not %T", + key); + return NULL; +} + +static PyObject * +complexstr_concat(PyObject *a, PyObject *b) +{ + cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(a)); + if (!Py_IS_TYPE(b, state->complexstr_type)) { + Py_RETURN_NOTIMPLEMENTED; + } + PyCursesComplexStrObject *sa = _PyCursesComplexStrObject_CAST(a); + PyCursesComplexStrObject *sb = _PyCursesComplexStrObject_CAST(b); + PyTypeObject *type = state->complexstr_type; + PyObject *res = type->tp_alloc(type, Py_SIZE(sa) + Py_SIZE(sb)); + if (res == NULL) { + return NULL; + } + cchar_t *out = _PyCursesComplexStrObject_CAST(res)->cells; + if (Py_SIZE(sa)) { + memcpy(out, sa->cells, (size_t)Py_SIZE(sa) * sizeof(cchar_t)); + } + if (Py_SIZE(sb)) { + memcpy(out + Py_SIZE(sa), sb->cells, (size_t)Py_SIZE(sb) * sizeof(cchar_t)); + } + return res; +} + +static PyObject * +complexstr_str(PyObject *self) +{ + PyCursesComplexStrObject *s = _PyCursesComplexStrObject_CAST(self); + if (Py_SIZE(s) == 0) { + return Py_GetConstant(Py_CONSTANT_EMPTY_STR); + } + wchar_t *buf = PyMem_New(wchar_t, Py_SIZE(s) * CCHARW_MAX + 1); + if (buf == NULL) { + return PyErr_NoMemory(); + } + Py_ssize_t pos = 0; + for (Py_ssize_t i = 0; i < Py_SIZE(s); i++) { + attr_t attrs; + int pair; + /* getcchar() writes the cell's text (and a terminator) at buf + pos; + the next cell overwrites the terminator. */ + if (curses_getcchar(&s->cells[i], buf + pos, &attrs, &pair) == ERR) { + cursesmodule_state *state = + get_cursesmodule_state_by_cls(Py_TYPE(self)); + PyErr_SetString(state->error, "getcchar() returned ERR"); + PyMem_Free(buf); + return NULL; + } + pos += wcslen(buf + pos); + } + PyObject *res = PyUnicode_FromWideChar(buf, pos); + PyMem_Free(buf); + return res; +} + +static PyObject * +complexstr_repr(PyObject *self) +{ + PyObject *list = PySequence_List(self); + if (list == NULL) { + return NULL; + } + PyObject *res = PyUnicode_FromFormat("%T(%R)", self, list); + Py_DECREF(list); + return res; +} + +static Py_hash_t +complexstr_hash(PyObject *self) +{ + PyCursesComplexStrObject *s = _PyCursesComplexStrObject_CAST(self); + cursesmodule_state *state = get_cursesmodule_state_by_cls(Py_TYPE(self)); + /* Combine the per-cell hashes like a tuple. */ + Py_uhash_t acc = _PyTuple_HASH_XXPRIME_5; + for (Py_ssize_t i = 0; i < Py_SIZE(s); i++) { + Py_hash_t lane = curses_cchar_hash(state, &s->cells[i]); + if (lane == -1) { + return -1; + } + acc += (Py_uhash_t)lane * _PyTuple_HASH_XXPRIME_2; + acc = _PyTuple_HASH_XXROTATE(acc); + acc *= _PyTuple_HASH_XXPRIME_1; + } + acc += Py_SIZE(s) ^ (_PyTuple_HASH_XXPRIME_5 ^ 3527539); + if (acc == (Py_uhash_t)-1) { + acc = 1546275796; + } + return (Py_hash_t)acc; +} + +static PyObject * +complexstr_richcompare(PyObject *self, PyObject *other, int op) +{ + if ((op != Py_EQ && op != Py_NE) || !Py_IS_TYPE(other, Py_TYPE(self))) { + Py_RETURN_NOTIMPLEMENTED; + } + PyCursesComplexStrObject *a = _PyCursesComplexStrObject_CAST(self); + PyCursesComplexStrObject *b = _PyCursesComplexStrObject_CAST(other); + int equal = (Py_SIZE(a) == Py_SIZE(b)); + for (Py_ssize_t i = 0; equal && i < Py_SIZE(a); i++) { + wchar_t wa[CCHARW_MAX + 1], wb[CCHARW_MAX + 1]; + attr_t aa, ab; + int pa, pb; + if (curses_getcchar(&a->cells[i], wa, &aa, &pa) == ERR || + curses_getcchar(&b->cells[i], wb, &ab, &pb) == ERR) + { + cursesmodule_state *state = + get_cursesmodule_state_by_cls(Py_TYPE(self)); + PyErr_SetString(state->error, "getcchar() returned ERR"); + return NULL; + } + equal = (aa == ab && pa == pb && wcscmp(wa, wb) == 0); + } + return PyBool_FromLong(equal == (op == Py_EQ)); +} + +/* Write (insert=0) or insert (insert=1) a complexstr's cells, using its buffer + directly, at the current or given position. n_limit < 0 means the whole run. + Returns None or NULL with an exception. */ +static PyObject * +curses_window_put_cells(PyCursesWindowObject *self, PyObject *obj, + int use_xy, int y, int x, int n_limit, int insert, + const char *funcname) +{ + const cchar_t *cells = _PyCursesComplexStrObject_CAST(obj)->cells; + Py_ssize_t count = Py_SIZE(obj); + + if (n_limit >= 0 && count > n_limit) { + count = n_limit; + } + + if (count == 0) { + /* Nothing to write; just honour the optional move, like an empty + string would. */ + int rtn = use_xy ? wmove(self->win, y, x) : OK; + return curses_window_check_err(self, rtn, "wmove", funcname); + } + + int rtn; + const char *cfuncname; + if (insert) { + /* There is no batch cchar_t insert; insert the cells right-to-left at + the position so they end up in order. */ + if (use_xy && wmove(self->win, y, x) == ERR) { + curses_window_set_error(self, "wmove", funcname); + return NULL; + } + rtn = OK; + cfuncname = "wins_wch"; + for (Py_ssize_t i = count - 1; i >= 0; i--) { + rtn = wins_wch(self->win, &cells[i]); + if (rtn == ERR) { + break; + } + } + } + else if (use_xy) { + rtn = mvwadd_wchnstr(self->win, y, x, cells, (int)count); + cfuncname = "mvwadd_wchnstr"; + } + else { + rtn = wadd_wchnstr(self->win, cells, (int)count); + cfuncname = "wadd_wchnstr"; + } + return curses_window_check_err(self, rtn, cfuncname, funcname); +} + #endif /***************************************************************************** @@ -1523,6 +1961,18 @@ _curses_window_addstr_impl(PyCursesWindowObject *self, int group_left_1, const char *funcname; #ifdef HAVE_NCURSESW + { + cursesmodule_state *state = get_cursesmodule_state_by_win(self); + if (Py_IS_TYPE(str, state->complexstr_type)) { + if (use_attr) { + PyErr_SetString(PyExc_TypeError, "addstr(): attr cannot be " + "specified together with a complexstr"); + return NULL; + } + return curses_window_put_cells(self, str, use_xy, y, x, + -1, 0, "addstr"); + } + } strtype = PyCurses_ConvertToString(self, str, &bytesobj, &wstr); #else strtype = PyCurses_ConvertToString(self, str, &bytesobj, NULL); @@ -1626,6 +2076,18 @@ _curses_window_addnstr_impl(PyCursesWindowObject *self, int group_left_1, const char *funcname; #ifdef HAVE_NCURSESW + { + cursesmodule_state *state = get_cursesmodule_state_by_win(self); + if (Py_IS_TYPE(str, state->complexstr_type)) { + if (use_attr) { + PyErr_SetString(PyExc_TypeError, "addnstr(): attr cannot be " + "specified together with a complexstr"); + return NULL; + } + return curses_window_put_cells(self, str, use_xy, y, x, + n, 0, "addnstr"); + } + } strtype = PyCurses_ConvertToString(self, str, &bytesobj, &wstr); #else strtype = PyCurses_ConvertToString(self, str, &bytesobj, NULL); @@ -3026,6 +3488,73 @@ PyCursesWindow_in_wstr(PyObject *op, PyObject *args) PyMem_Free(buf); return res; } + +PyDoc_STRVAR(_curses_window_in_wchstr__doc__, +"in_wchstr([y, x,] n=2047)\n" +"Return a complexstr of the styled cells extracted from the window.\n" +"\n" +" y\n" +" Y-coordinate.\n" +" x\n" +" X-coordinate.\n" +" n\n" +" Maximal number of cells.\n" +"\n" +"This is the wide-character variant of instr() and in_wstr() that keeps\n" +"each cell's attributes and color pair; it returns a complexstr."); + +static PyObject * +PyCursesWindow_in_wchstr(PyObject *op, PyObject *args) +{ + PyCursesWindowObject *self = _PyCursesWindowObject_CAST(op); + int rtn, use_xy = 0, y = 0, x = 0; + unsigned int max_buf_size = 2048; + unsigned int n = max_buf_size - 1; + + if (!curses_clinic_parse_optional_xy_n(args, &y, &x, &n, &use_xy, + "_curses.window.in_wchstr")) + { + return NULL; + } + + n = Py_MIN(n, max_buf_size - 1); + cchar_t *buf = PyMem_New(cchar_t, n + 1); + if (buf == NULL) { + return PyErr_NoMemory(); + } + + if (use_xy) { + rtn = mvwin_wchnstr(self->win, y, x, buf, n); + } + else { + rtn = win_wchnstr(self->win, buf, n); + } + + cursesmodule_state *state = get_cursesmodule_state_by_win(self); + if (rtn == ERR) { + PyMem_Free(buf); + return PyCursesComplexStr_New(state, NULL, 0); + } + + /* win_wchnstr() stores at most n cells and zero-terminates the array at + the actual count; every real cell holds at least a space, so the first + empty cell marks the end of the run. */ + Py_ssize_t count = 0; + while (count < (Py_ssize_t)n) { + wchar_t wstr[CCHARW_MAX + 1]; + attr_t attrs; + int pair; + if (curses_getcchar(&buf[count], wstr, &attrs, &pair) == ERR + || wstr[0] == L'\0') + { + break; + } + count++; + } + PyObject *res = PyCursesComplexStr_New(state, buf, count); + PyMem_Free(buf); + return res; +} #endif /* HAVE_NCURSESW */ /*[clinic input] @@ -3073,6 +3602,18 @@ _curses_window_insstr_impl(PyCursesWindowObject *self, int group_left_1, const char *funcname; #ifdef HAVE_NCURSESW + { + cursesmodule_state *state = get_cursesmodule_state_by_win(self); + if (Py_IS_TYPE(str, state->complexstr_type)) { + if (use_attr) { + PyErr_SetString(PyExc_TypeError, "insstr(): attr cannot be " + "specified together with a complexstr"); + return NULL; + } + return curses_window_put_cells(self, str, use_xy, y, x, + -1, 1, "insstr"); + } + } strtype = PyCurses_ConvertToString(self, str, &bytesobj, &wstr); #else strtype = PyCurses_ConvertToString(self, str, &bytesobj, NULL); @@ -3174,6 +3715,18 @@ _curses_window_insnstr_impl(PyCursesWindowObject *self, int group_left_1, const char *funcname; #ifdef HAVE_NCURSESW + { + cursesmodule_state *state = get_cursesmodule_state_by_win(self); + if (Py_IS_TYPE(str, state->complexstr_type)) { + if (use_attr) { + PyErr_SetString(PyExc_TypeError, "insnstr(): attr cannot be " + "specified together with a complexstr"); + return NULL; + } + return curses_window_put_cells(self, str, use_xy, y, x, + n, 1, "insnstr"); + } + } strtype = PyCurses_ConvertToString(self, str, &bytesobj, &wstr); #else strtype = PyCurses_ConvertToString(self, str, &bytesobj, NULL); @@ -3817,6 +4370,32 @@ static PyType_Spec PyCursesComplexChar_Type_spec = { | Py_TPFLAGS_HEAPTYPE, .slots = PyCursesComplexChar_Type_slots }; + +static PyType_Slot PyCursesComplexStr_Type_slots[] = { + {Py_tp_doc, (void *)complexstr_new__doc__}, + {Py_tp_new, complexstr_new}, + {Py_tp_dealloc, complexstr_dealloc}, + {Py_tp_repr, complexstr_repr}, + {Py_tp_str, complexstr_str}, + {Py_tp_richcompare, complexstr_richcompare}, + {Py_tp_hash, complexstr_hash}, + {Py_sq_length, complexstr_length}, + {Py_sq_concat, complexstr_concat}, + {Py_sq_item, complexstr_item}, + {Py_mp_length, complexstr_length}, + {Py_mp_subscript, complexstr_subscript}, + {0, NULL} +}; + +static PyType_Spec PyCursesComplexStr_Type_spec = { + .name = "_curses.complexstr", + .basicsize = offsetof(PyCursesComplexStrObject, cells), + .itemsize = sizeof(cchar_t), + .flags = Py_TPFLAGS_DEFAULT + | Py_TPFLAGS_IMMUTABLETYPE + | Py_TPFLAGS_HEAPTYPE, + .slots = PyCursesComplexStr_Type_slots +}; #endif #undef clinic_state @@ -4021,6 +4600,10 @@ static PyMethodDef PyCursesWindow_methods[] = { "in_wstr", PyCursesWindow_in_wstr, METH_VARARGS, _curses_window_in_wstr__doc__ }, + { + "in_wchstr", PyCursesWindow_in_wchstr, METH_VARARGS, + _curses_window_in_wchstr__doc__ + }, #endif _CURSES_WINDOW_IS_LINETOUCHED_METHODDEF {"is_wintouched", PyCursesWindow_is_wintouched, METH_NOARGS, @@ -7238,6 +7821,7 @@ cursesmodule_traverse(PyObject *mod, visitproc visit, void *arg) Py_VISIT(state->screen_type); #ifdef HAVE_NCURSESW Py_VISIT(state->complexchar_type); + Py_VISIT(state->complexstr_type); #endif Py_VISIT(state->topscreen); return 0; @@ -7252,6 +7836,7 @@ cursesmodule_clear(PyObject *mod) Py_CLEAR(state->screen_type); #ifdef HAVE_NCURSESW Py_CLEAR(state->complexchar_type); + Py_CLEAR(state->complexstr_type); #endif Py_CLEAR(state->topscreen); return 0; @@ -7303,6 +7888,14 @@ cursesmodule_exec(PyObject *module) if (PyModule_AddType(module, state->complexchar_type) < 0) { return -1; } + state->complexstr_type = (PyTypeObject *)PyType_FromModuleAndSpec( + module, &PyCursesComplexStr_Type_spec, NULL); + if (state->complexstr_type == NULL) { + return -1; + } + if (PyModule_AddType(module, state->complexstr_type) < 0) { + return -1; + } #endif /* Add some symbolic constants to the module */ diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index 4a87ec3e215e..fc48af636346 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -105,6 +105,97 @@ exit: #endif /* defined(HAVE_NCURSESW) */ +#if defined(HAVE_NCURSESW) + +PyDoc_STRVAR(complexstr_new__doc__, +"complexstr(cells, /, attr=, pair=)\n" +"--\n" +"\n" +"An immutable string of styled wide-character cells.\n" +"\n" +" cells\n" +" An iterable of cells, each a complexchar or a str.\n" +" attr\n" +" Attributes applied to every cell (only with a string).\n" +" pair\n" +" Color pair applied to every cell (only with a string).\n" +"\n" +"It is the counterpart of complexchar for a run of cells, and the type\n" +"returned by window.in_wchstr(). Each cell is a complexchar or a str (a\n" +"spacing character optionally followed by combining characters).\n" +"\n" +"When cells is a string it is split into character cells, and attr and\n" +"pair (if given) style every cell. Otherwise each item carries its own\n" +"rendition, and attr and pair must be omitted."); + +static PyObject * +complexstr_new_impl(PyTypeObject *type, PyObject *cells, PyObject *attr, + PyObject *pair); + +static PyObject * +complexstr_new(PyTypeObject *type, PyObject *args, PyObject *kwargs) +{ + PyObject *return_value = NULL; + #if defined(Py_BUILD_CORE) && !defined(Py_BUILD_CORE_MODULE) + + #define NUM_KEYWORDS 2 + static struct { + PyGC_Head _this_is_not_used; + PyObject_VAR_HEAD + Py_hash_t ob_hash; + PyObject *ob_item[NUM_KEYWORDS]; + } _kwtuple = { + .ob_base = PyVarObject_HEAD_INIT(&PyTuple_Type, NUM_KEYWORDS) + .ob_hash = -1, + .ob_item = { &_Py_ID(attr), &_Py_ID(pair), }, + }; + #undef NUM_KEYWORDS + #define KWTUPLE (&_kwtuple.ob_base.ob_base) + + #else // !Py_BUILD_CORE + # define KWTUPLE NULL + #endif // !Py_BUILD_CORE + + static const char * const _keywords[] = {"", "attr", "pair", NULL}; + static _PyArg_Parser _parser = { + .keywords = _keywords, + .fname = "complexstr", + .kwtuple = KWTUPLE, + }; + #undef KWTUPLE + PyObject *argsbuf[3]; + PyObject * const *fastargs; + Py_ssize_t nargs = PyTuple_GET_SIZE(args); + Py_ssize_t noptargs = nargs + (kwargs ? PyDict_GET_SIZE(kwargs) : 0) - 1; + PyObject *cells; + PyObject *attr = NULL; + PyObject *pair = NULL; + + fastargs = _PyArg_UnpackKeywords(_PyTuple_CAST(args)->ob_item, nargs, kwargs, NULL, &_parser, + /*minpos*/ 1, /*maxpos*/ 3, /*minkw*/ 0, /*varpos*/ 0, argsbuf); + if (!fastargs) { + goto exit; + } + cells = fastargs[0]; + if (!noptargs) { + goto skip_optional_pos; + } + if (fastargs[1]) { + attr = fastargs[1]; + if (!--noptargs) { + goto skip_optional_pos; + } + } + pair = fastargs[2]; +skip_optional_pos: + return_value = complexstr_new_impl(type, cells, attr, pair); + +exit: + return return_value; +} + +#endif /* defined(HAVE_NCURSESW) */ + PyDoc_STRVAR(_curses_window_addch__doc__, "addch([y, x,] ch, [attr])\n" "Paint the character.\n" @@ -5309,4 +5400,4 @@ _curses_has_extended_color_support(PyObject *module, PyObject *Py_UNUSED(ignored #ifndef _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #define _CURSES_ASSUME_DEFAULT_COLORS_METHODDEF #endif /* !defined(_CURSES_ASSUME_DEFAULT_COLORS_METHODDEF) */ -/*[clinic end generated code: output=081cc398989ca202 input=a9049054013a1b77]*/ +/*[clinic end generated code: output=7940d7d4775b58fd input=a9049054013a1b77]*/ -- 2.47.3