diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst index 04cf2e17d538fcc..642cfdb7b1b8ae1 100644 --- a/Doc/library/curses.rst +++ b/Doc/library/curses.rst @@ -1582,10 +1582,14 @@ Borders and lines | *br* | Bottom-right corner | :const:`ACS_LRCORNER` | +-----------+---------------------+-----------------------+ + If any parameter is a byte character or an integer other than ``0``, the + border is drawn from byte characters, and every string character must be + encodable as a single byte. + .. versionchanged:: next Wide and combining characters, and :class:`complexchar` cells, are now accepted. A single call cannot mix - them with integer or byte characters. + :class:`complexchar` cells with integer or byte characters. .. method:: window.box([vertch, horch]) @@ -1595,7 +1599,7 @@ Borders and lines .. versionchanged:: next Wide and combining characters, and :class:`complexchar` cells, are now accepted. A single call cannot mix - them with integer or byte characters. + :class:`complexchar` cells with integer or byte characters. .. method:: window.hline(ch, n[, attr]) window.hline(y, x, ch, n[, attr]) diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py index d87374a298fc337..26916779dac868d 100644 --- a/Lib/test/test_curses.py +++ b/Lib/test/test_curses.py @@ -467,8 +467,76 @@ def test_wide_characters(self): if self._encodable(vline + hline): stdscr.border(vline, vline, hline, hline) stdscr.box(vline, hline) - # border() and box() cannot mix integer and wide-string characters. - self.assertRaises(TypeError, stdscr.box, vline, ord('-')) + # border() and box() cannot mix a complexchar with an integer + # character; a wide string character is narrowed instead, which only + # works if it is a single byte. + self.assertRaises(TypeError, stdscr.box, + curses.complexchar(vline), ord('-')) + + @requires_wide_build + def test_border_default_characters(self): + # 0 requests the default character, as an omitted argument does, + # even in a border drawn with wide characters. + win = curses.newwin(5, 10, 5, 2) + maxy, maxx = win.getmaxyx() + corners = [(0, 0), (0, maxx-1), (maxy-1, 0), (maxy-1, maxx-1)] + win.border('|', '|', '-', '-', 0, 0, 0, 0) + with_zeros = [win.in_wch(y, x) for y, x in corners] + win.erase() + win.border('|', '|', '-', '-') + self.assertEqual([win.in_wch(y, x) for y, x in corners], with_zeros) + win.border(0, '|', 0, '-', 0, 0, 0, 0) + vline = curses.complexchar('|') + hline = curses.complexchar('-') + win.border(vline, vline, hline, hline, 0, 0, 0, 0) + # box() takes 0 for either side, and draws the same default + # characters as an omitted border() argument. + win.erase() + win.border('|', '|') + default_corner = win.in_wch(0, 0) + default_hline = win.in_wch(0, 1) + win.erase() + win.border(0, 0, '-', '-') + default_vline = win.in_wch(1, 0) + win.erase() + win.box('|', 0) + self.assertEqual(win.in_wch(0, 0), default_corner) + self.assertEqual(win.in_wch(0, 1), default_hline) + win.erase() + win.box(0, '-') + self.assertEqual(win.in_wch(1, 0), default_vline) + win.box(vline, 0) + + @requires_wide_build + def test_border_mixed_characters(self): + # Integer and bytes characters other than 0 are only drawn by the + # narrow function, which draws string characters as single bytes. + win = curses.newwin(5, 10, 5, 2) + win.border('|', '|', '-', '-', 65, 66, 67, 68) + self.assertEqual(win.instr(0, 0), b'A--------B') + self.assertEqual(win.instr(1, 0), b'| |') + self.assertEqual(win.instr(4, 0), b'C--------D') + win.border('|', b'!') + self.assertEqual(win.instr(1, 0), b'| !') + # b'\0' is a byte character, not the sentinel, but the narrow function + # draws a zero character as the default one. + win.border('|', b'\0') + # A complexchar cannot be drawn as a byte. + cc = curses.complexchar('|') + self.assertRaises(TypeError, win.border, cc, 65) + self.assertRaises(TypeError, win.border, cc, b'!') + # Neither can a string character that is not a single byte. + vline = '\u2502' + if len(vline.encode(win.encoding, 'replace')) != 1: + self.assertRaises(OverflowError, win.border, vline, 65) + # box() follows the same rules. + win.box('|', 45) + self.assertEqual(win.instr(1, 0), b'| |') + win.box(b'|', '-') + self.assertRaises(TypeError, win.box, cc, 45) + self.assertRaises(TypeError, win.box, cc, b'-') + if len(vline.encode(win.encoding, 'replace')) != 1: + self.assertRaises(OverflowError, win.box, vline, 45) def test_complexchar_in_cell_methods(self): # Every single-character-cell method also accepts a complexchar, whose diff --git a/Misc/NEWS.d/next/Library/2026-08-15-17-52-30.gh-issue-155499.Hs8Tqm.rst b/Misc/NEWS.d/next/Library/2026-08-15-17-52-30.gh-issue-155499.Hs8Tqm.rst new file mode 100644 index 000000000000000..b0948575688cd1a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-15-17-52-30.gh-issue-155499.Hs8Tqm.rst @@ -0,0 +1,4 @@ +:meth:`curses.window.border` and :meth:`curses.window.box` accept again ``0`` +as the request for the default character, and no longer reject a string +character passed together with an integer or byte character. Only a +:class:`curses.complexchar` cannot be mixed with the latter. diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c index 383de378670ea97..f04b295486b4fc6 100644 --- a/Modules/_cursesmodule.c +++ b/Modules/_cursesmodule.c @@ -2647,21 +2647,21 @@ _curses_window_bkgdset_impl(PyCursesWindowObject *self, PyObject *ch, /*[clinic input] _curses.window.border - ls: object(c_default="NULL") = _curses.ACS_VLINE + ls: object(c_default="NULL") = 0 Left side. - rs: object(c_default="NULL") = _curses.ACS_VLINE + rs: object(c_default="NULL") = 0 Right side. - ts: object(c_default="NULL") = _curses.ACS_HLINE + ts: object(c_default="NULL") = 0 Top side. - bs: object(c_default="NULL") = _curses.ACS_HLINE + bs: object(c_default="NULL") = 0 Bottom side. - tl: object(c_default="NULL") = _curses.ACS_ULCORNER + tl: object(c_default="NULL") = 0 Upper-left corner. - tr: object(c_default="NULL") = _curses.ACS_URCORNER + tr: object(c_default="NULL") = 0 Upper-right corner. - bl: object(c_default="NULL") = _curses.ACS_LLCORNER + bl: object(c_default="NULL") = 0 Bottom-left corner. - br: object(c_default="NULL") = _curses.ACS_LRCORNER + br: object(c_default="NULL") = 0 Bottom-right corner. / @@ -2678,7 +2678,7 @@ _curses_window_border_impl(PyCursesWindowObject *self, PyObject *ls, PyObject *rs, PyObject *ts, PyObject *bs, PyObject *tl, PyObject *tr, PyObject *bl, PyObject *br) -/*[clinic end generated code: output=670ef38d3d7c2aa3 input=42568c1458221d24]*/ +/*[clinic end generated code: output=670ef38d3d7c2aa3 input=d826ce9d6335479a]*/ { chtype ch[8]; int i, rtn; @@ -2691,36 +2691,49 @@ _curses_window_border_impl(PyCursesWindowObject *self, PyObject *ls, #ifdef HAVE_NCURSESW cchar_t wch[8]; const cchar_t *wch_p[8]; - int use_wide = 0; - int types[8]; + /* Only wborder_set() draws a complexchar and only wborder() an integer + or bytes character; a string character suits both, and so does the + integer 0, which asks for the default character. */ + int has_narrow = 0, has_str = 0, has_cchar = 0; for (i = 0; i < 8; i++) { - types[i] = 0; + wch_p[i] = NULL; /* use the default character */ if (objs[i] != NULL) { - types[i] = PyCurses_ConvertToCell(self, objs[i], A_NORMAL, 0, + int type = PyCurses_ConvertToCell(self, objs[i], A_NORMAL, 0, "border", &ch[i], &wch[i]); - if (types[i] == 0) { + if (type == 0) { return NULL; } - if (types[i] == 2) { - use_wide = 1; + if (type == 2) { + wch_p[i] = &wch[i]; + if (PyUnicode_Check(objs[i])) { + has_str = 1; + } + else { + has_cchar = 1; + } + } + else if (!PyLong_CheckExact(objs[i]) || ch[i] != 0) { + has_narrow = 1; /* b'\0' is a byte character, not the 0 */ } } } - if (use_wide) { + if (has_narrow) { + if (has_cchar) { + PyErr_SetString(PyExc_TypeError, + "border() cannot mix complexchar characters " + "with integer or bytes characters"); + return NULL; + } + /* Narrow the string characters. */ for (i = 0; i < 8; i++) { - if (objs[i] == NULL) { - wch_p[i] = NULL; /* use the default character */ - } - else if (types[i] == 2) { - wch_p[i] = &wch[i]; - } - else { - PyErr_SetString(PyExc_TypeError, - "border() cannot mix integer or bytes " - "characters with wide string characters"); + if (objs[i] != NULL && PyUnicode_Check(objs[i]) && + !PyCurses_ConvertToChtype(self, objs[i], &ch[i])) + { return NULL; } } + } + else if (has_str || has_cchar) { rtn = wborder_set(self->win, wch_p[0], wch_p[1], wch_p[2], wch_p[3], wch_p[4], wch_p[5], wch_p[6], wch_p[7]); @@ -2763,42 +2776,67 @@ _curses_window_box_impl(PyCursesWindowObject *self, int group_right_1, PyObject *verch, PyObject *horch) /*[clinic end generated code: output=f3fcb038bb287192 input=e11acb7dbf6790b6]*/ { - chtype ch1 = 0, ch2 = 0; + chtype ch[2] = {0, 0}; + PyObject *objs[2] = {verch, horch}; + int i; #ifdef HAVE_NCURSESW - cchar_t wch1, wch2; - int t1 = 0, t2 = 0; + cchar_t wch[2]; + const cchar_t *wch_p[2] = {NULL, NULL}; + int has_narrow = 0, has_str = 0, has_cchar = 0; if (group_right_1) { - t1 = PyCurses_ConvertToCell(self, verch, A_NORMAL, 0, "box", &ch1, &wch1); - if (t1 == 0) { - return NULL; - } - t2 = PyCurses_ConvertToCell(self, horch, A_NORMAL, 0, "box", &ch2, &wch2); - if (t2 == 0) { - return NULL; + for (i = 0; i < 2; i++) { + int type = PyCurses_ConvertToCell(self, objs[i], A_NORMAL, 0, + "box", &ch[i], &wch[i]); + if (type == 0) { + return NULL; + } + if (type == 2) { + wch_p[i] = &wch[i]; + if (PyUnicode_Check(objs[i])) { + has_str = 1; + } + else { + has_cchar = 1; + } + } + else if (!PyLong_CheckExact(objs[i]) || ch[i] != 0) { + has_narrow = 1; /* b'\0' is a byte character, not the 0 */ + } } } - if (t1 == 2 || t2 == 2) { - if (t1 != 2 || t2 != 2) { + if (has_narrow) { + if (has_cchar) { PyErr_SetString(PyExc_TypeError, - "box() cannot mix integer or bytes characters " - "with wide string characters"); + "box() cannot mix complexchar characters " + "with integer or bytes characters"); return NULL; } - int rtn = wborder_set(self->win, &wch1, &wch1, &wch2, &wch2, - NULL, NULL, NULL, NULL); - return curses_window_check_err(self, rtn, "wborder_set", "box"); + /* Narrow the string characters. */ + for (i = 0; i < 2; i++) { + if (PyUnicode_Check(objs[i]) && + !PyCurses_ConvertToChtype(self, objs[i], &ch[i])) + { + return NULL; + } + } + } + else if (has_str || has_cchar) { + int rtn = box_set(self->win, wch_p[0], wch_p[1]); + return curses_window_check_err(self, rtn, "box_set", "box"); } #else if (group_right_1) { - if (!PyCurses_ConvertToCell(self, verch, A_NORMAL, 0, "box", &ch1)) { - return NULL; - } - if (!PyCurses_ConvertToCell(self, horch, A_NORMAL, 0, "box", &ch2)) { - return NULL; + for (i = 0; i < 2; i++) { + if (!PyCurses_ConvertToCell(self, objs[i], A_NORMAL, 0, "box", + &ch[i])) + { + return NULL; + } } } #endif - return curses_window_check_err(self, box(self->win, ch1, ch2), "box", NULL); + return curses_window_check_err(self, box(self->win, ch[0], ch[1]), + "box", NULL); } #if defined(HAVE_NCURSES_H) || defined(MVWDELCH_IS_EXPRESSION) diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h index b4cb294e3bb61a5..d2f30178b1c33c7 100644 --- a/Modules/clinic/_cursesmodule.c.h +++ b/Modules/clinic/_cursesmodule.c.h @@ -757,10 +757,7 @@ _curses_window_bkgdset(PyObject *self, PyObject *args) } PyDoc_STRVAR(_curses_window_border__doc__, -"border($self, ls=_curses.ACS_VLINE, rs=_curses.ACS_VLINE,\n" -" ts=_curses.ACS_HLINE, bs=_curses.ACS_HLINE,\n" -" tl=_curses.ACS_ULCORNER, tr=_curses.ACS_URCORNER,\n" -" bl=_curses.ACS_LLCORNER, br=_curses.ACS_LRCORNER, /)\n" +"border($self, ls=0, rs=0, ts=0, bs=0, tl=0, tr=0, bl=0, br=0, /)\n" "--\n" "\n" "Draw a border around the edges of the window.\n" @@ -6585,4 +6582,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=680f621e7c1f101b input=a9049054013a1b77]*/ +/*[clinic end generated code: output=4e98ddbfb69f2c04 input=a9049054013a1b77]*/