https://github.com/python/cpython/commit/306d7a7649061d4c88d90deb6838e7e49d03269e
commit: 306d7a7649061d4c88d90deb6838e7e49d03269e
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-06-28T14:09:03Z
summary:

gh-152325: Add curses.has_mouse() and curses.window.mouse_trafo() (GH-152484)

has_mouse() reports whether the mouse driver was successfully initialized.

window.mouse_trafo(y, x, to_screen) converts a coordinate pair between
window-relative and screen-relative coordinates, returning the (y, x) pair or
None if it lies outside the window.  Together these complete the curses mouse
interface.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-06-26-21-30-00.gh-issue-152325.Km9xQr.rst
M Doc/library/curses.rst
M Doc/whatsnew/3.16.rst
M Lib/test/test_curses.py
M Modules/_cursesmodule.c
M Modules/clinic/_cursesmodule.c.h

diff --git a/Doc/library/curses.rst b/Doc/library/curses.rst
index f87ab7a8ee063d..86be23c931019e 100644
--- a/Doc/library/curses.rst
+++ b/Doc/library/curses.rst
@@ -346,6 +346,13 @@ The module :mod:`!curses` defines the following functions:
    a key with that value.
 
 
+.. function:: has_mouse()
+
+   Return ``True`` if the mouse driver has been successfully initialized.
+
+   .. versionadded:: next
+
+
 .. function:: define_key(definition, keycode)
 
    Define an escape sequence *definition*, a string, as a key that generates
@@ -1309,6 +1316,18 @@ Window objects
       Previously it returned ``1`` or ``0`` instead of ``True`` or ``False``.
 
 
+.. method:: window.mouse_trafo(y, x, to_screen)
+
+   Convert between window-relative and screen-relative (``stdscr``-relative) 
character-cell coordinates.
+   If *to_screen* is true, convert the window-relative coordinates *y*, *x* to 
screen-relative coordinates;
+   otherwise convert in the opposite direction.
+   The two coordinate systems differ when lines are reserved on the screen, 
for example for soft labels.
+
+   Return the converted coordinates as a ``(y, x)`` tuple, or ``None`` if they 
lie outside the window.
+
+   .. versionadded:: next
+
+
 .. attribute:: window.encoding
 
    Encoding used to encode method arguments (Unicode strings and characters).
diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst
index 23b88c5d279c2d..cde44221e05749 100644
--- a/Doc/whatsnew/3.16.rst
+++ b/Doc/whatsnew/3.16.rst
@@ -192,6 +192,11 @@ curses
   against an ncurses with ``NCURSES_EXT_FUNCS``.
   (Contributed by Serhiy Storchaka in :gh:`152334`.)
 
+* Add the :func:`curses.has_mouse` function and the
+  :meth:`curses.window.mouse_trafo` method, completing the :mod:`curses`
+  mouse interface.
+  (Contributed by Serhiy Storchaka in :gh:`152325`.)
+
 * :class:`curses.textpad.Textbox` now supports entering and reading back the
   full Unicode range, including combining characters, when curses is built with
   wide-character support.
diff --git a/Lib/test/test_curses.py b/Lib/test/test_curses.py
index 20b1441d98584c..37caee79837888 100644
--- a/Lib/test/test_curses.py
+++ b/Lib/test/test_curses.py
@@ -1305,6 +1305,22 @@ def test_enclose(self):
         self.assertIs(win.enclose(7, 19), False)
         self.assertIs(win.enclose(6, 20), False)
 
+    @requires_curses_window_meth('mouse_trafo')
+    def test_mouse_trafo(self):
+        win = curses.newwin(5, 15, 2, 5)
+        # to_screen=True: window-relative -> stdscr-relative.
+        self.assertEqual(win.mouse_trafo(0, 0, True), (2, 5))
+        self.assertEqual(win.mouse_trafo(3, 10, True), (5, 15))
+        self.assertEqual(win.mouse_trafo(4, 14, True), (6, 19))
+        # A coordinate outside the window has no counterpart.
+        self.assertIsNone(win.mouse_trafo(5, 0, True))
+        self.assertIsNone(win.mouse_trafo(0, 15, True))
+        # to_screen=False is the inverse: stdscr-relative -> window-relative.
+        self.assertEqual(win.mouse_trafo(2, 5, False), (0, 0))
+        self.assertEqual(win.mouse_trafo(6, 19, False), (4, 14))
+        self.assertIsNone(win.mouse_trafo(1, 5, False))
+        self.assertIsNone(win.mouse_trafo(7, 19, False))
+
     def test_putwin(self):
         win = curses.newwin(5, 12, 1, 2)
         win.addstr(2, 1, 'Lorem ipsum')
@@ -1824,6 +1840,11 @@ def test_has_colors(self):
         self.assertIsInstance(curses.has_colors(), bool)
         self.assertIsInstance(curses.can_change_color(), bool)
 
+    @requires_curses_func('has_mouse')
+    def test_has_mouse(self):
+        # Whether a mouse is available depends on the terminal.
+        self.assertIsInstance(curses.has_mouse(), bool)
+
     def test_start_color(self):
         if not curses.has_colors():
             self.skipTest('requires colors support')
diff --git 
a/Misc/NEWS.d/next/Library/2026-06-26-21-30-00.gh-issue-152325.Km9xQr.rst 
b/Misc/NEWS.d/next/Library/2026-06-26-21-30-00.gh-issue-152325.Km9xQr.rst
new file mode 100644
index 00000000000000..655f61cbb530b1
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-06-26-21-30-00.gh-issue-152325.Km9xQr.rst
@@ -0,0 +1,2 @@
+Add the :func:`curses.has_mouse` function and the
+:meth:`curses.window.mouse_trafo` method.
diff --git a/Modules/_cursesmodule.c b/Modules/_cursesmodule.c
index 46c453ac850c7e..4c183ce9a11db7 100644
--- a/Modules/_cursesmodule.c
+++ b/Modules/_cursesmodule.c
@@ -43,7 +43,7 @@
   del_curterm mcprint mvcur restartterm
   ripoffline set_curterm setterm
   tgetent tgetflag tgetnum tgetstr tgoto tputs
-  vidattr vidputs wmouse_trafo
+  vidattr vidputs
 
   Low-priority:
   slk_attr slk_attr_off slk_attr_on slk_attr_set slk_attroff
@@ -3007,6 +3007,36 @@ _curses_window_enclose_impl(PyCursesWindowObject *self, 
int y, int x)
 {
     return PyBool_FromLong(wenclose(self->win, y, x));
 }
+
+/*[clinic input]
+_curses.window.mouse_trafo
+
+    y: int
+        Y-coordinate.
+    x: int
+        X-coordinate.
+    to_screen: bool
+        If True, convert window-relative coordinates to
+        stdscr-relative ones; otherwise convert the other way.
+    /
+
+Convert coordinates between window-relative and screen-relative.
+
+Return the converted (y, x) coordinates, or None if they are
+outside the window.
+[clinic start generated code]*/
+
+static PyObject *
+_curses_window_mouse_trafo_impl(PyCursesWindowObject *self, int y, int x,
+                                int to_screen)
+/*[clinic end generated code: output=b21572fa3524c15d input=c51fd793af7f6965]*/
+{
+    int ry = y, rx = x;
+    if (!wmouse_trafo(self->win, &ry, &rx, to_screen)) {
+        Py_RETURN_NONE;
+    }
+    return Py_BuildValue("(ii)", ry, rx);
+}
 #endif
 
 /*[clinic input]
@@ -4836,6 +4866,7 @@ static PyMethodDef PyCursesWindow_methods[] = {
     _CURSES_WINDOW_DUPWIN_METHODDEF
     _CURSES_WINDOW_ECHOCHAR_METHODDEF
     _CURSES_WINDOW_ENCLOSE_METHODDEF
+    _CURSES_WINDOW_MOUSE_TRAFO_METHODDEF
     {"erase", PyCursesWindow_werase, METH_NOARGS,
      "erase($self, /)\n--\n\n"
      "Clear the window."},
@@ -6995,6 +7026,21 @@ _curses_meta_impl(PyObject *module, int yes)
 }
 
 #ifdef NCURSES_MOUSE_VERSION
+/*[clinic input]
+_curses.has_mouse
+
+Return True if the mouse driver has been successfully initialized.
+[clinic start generated code]*/
+
+static PyObject *
+_curses_has_mouse_impl(PyObject *module)
+/*[clinic end generated code: output=7901cc34069e4f57 input=94682101a11c4f30]*/
+{
+    PyCursesStatefulInitialised(module);
+
+    return PyBool_FromLong(has_mouse());
+}
+
 /*[clinic input]
 _curses.mouseinterval
 
@@ -8204,6 +8250,7 @@ static PyMethodDef cursesmodule_methods[] = {
     _CURSES_HAS_IC_METHODDEF
     _CURSES_HAS_IL_METHODDEF
     _CURSES_HAS_KEY_METHODDEF
+    _CURSES_HAS_MOUSE_METHODDEF
     _CURSES_DEFINE_KEY_METHODDEF
     _CURSES_KEY_DEFINED_METHODDEF
     _CURSES_KEYOK_METHODDEF
diff --git a/Modules/clinic/_cursesmodule.c.h b/Modules/clinic/_cursesmodule.c.h
index 8e0f922ad0444f..7b09b65d359d03 100644
--- a/Modules/clinic/_cursesmodule.c.h
+++ b/Modules/clinic/_cursesmodule.c.h
@@ -1093,6 +1093,63 @@ _curses_window_enclose(PyObject *self, PyObject *const 
*args, Py_ssize_t nargs)
 
 #endif /* defined(NCURSES_MOUSE_VERSION) */
 
+#if defined(NCURSES_MOUSE_VERSION)
+
+PyDoc_STRVAR(_curses_window_mouse_trafo__doc__,
+"mouse_trafo($self, y, x, to_screen, /)\n"
+"--\n"
+"\n"
+"Convert coordinates between window-relative and screen-relative.\n"
+"\n"
+"  y\n"
+"    Y-coordinate.\n"
+"  x\n"
+"    X-coordinate.\n"
+"  to_screen\n"
+"    If True, convert window-relative coordinates to\n"
+"    stdscr-relative ones; otherwise convert the other way.\n"
+"\n"
+"Return the converted (y, x) coordinates, or None if they are\n"
+"outside the window.");
+
+#define _CURSES_WINDOW_MOUSE_TRAFO_METHODDEF    \
+    {"mouse_trafo", _PyCFunction_CAST(_curses_window_mouse_trafo), 
METH_FASTCALL, _curses_window_mouse_trafo__doc__},
+
+static PyObject *
+_curses_window_mouse_trafo_impl(PyCursesWindowObject *self, int y, int x,
+                                int to_screen);
+
+static PyObject *
+_curses_window_mouse_trafo(PyObject *self, PyObject *const *args, Py_ssize_t 
nargs)
+{
+    PyObject *return_value = NULL;
+    int y;
+    int x;
+    int to_screen;
+
+    if (!_PyArg_CheckPositional("mouse_trafo", nargs, 3, 3)) {
+        goto exit;
+    }
+    y = PyLong_AsInt(args[0]);
+    if (y == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    x = PyLong_AsInt(args[1]);
+    if (x == -1 && PyErr_Occurred()) {
+        goto exit;
+    }
+    to_screen = PyObject_IsTrue(args[2]);
+    if (to_screen < 0) {
+        goto exit;
+    }
+    return_value = _curses_window_mouse_trafo_impl((PyCursesWindowObject 
*)self, y, x, to_screen);
+
+exit:
+    return return_value;
+}
+
+#endif /* defined(NCURSES_MOUSE_VERSION) */
+
 PyDoc_STRVAR(_curses_window_getbkgd__doc__,
 "getbkgd($self, /)\n"
 "--\n"
@@ -4161,6 +4218,28 @@ _curses_meta(PyObject *module, PyObject *arg)
 
 #if defined(NCURSES_MOUSE_VERSION)
 
+PyDoc_STRVAR(_curses_has_mouse__doc__,
+"has_mouse($module, /)\n"
+"--\n"
+"\n"
+"Return True if the mouse driver has been successfully initialized.");
+
+#define _CURSES_HAS_MOUSE_METHODDEF    \
+    {"has_mouse", (PyCFunction)_curses_has_mouse, METH_NOARGS, 
_curses_has_mouse__doc__},
+
+static PyObject *
+_curses_has_mouse_impl(PyObject *module);
+
+static PyObject *
+_curses_has_mouse(PyObject *module, PyObject *Py_UNUSED(ignored))
+{
+    return _curses_has_mouse_impl(module);
+}
+
+#endif /* defined(NCURSES_MOUSE_VERSION) */
+
+#if defined(NCURSES_MOUSE_VERSION)
+
 PyDoc_STRVAR(_curses_mouseinterval__doc__,
 "mouseinterval($module, interval, /)\n"
 "--\n"
@@ -5467,6 +5546,10 @@ _curses_has_extended_color_support(PyObject *module, 
PyObject *Py_UNUSED(ignored
     #define _CURSES_WINDOW_ENCLOSE_METHODDEF
 #endif /* !defined(_CURSES_WINDOW_ENCLOSE_METHODDEF) */
 
+#ifndef _CURSES_WINDOW_MOUSE_TRAFO_METHODDEF
+    #define _CURSES_WINDOW_MOUSE_TRAFO_METHODDEF
+#endif /* !defined(_CURSES_WINDOW_MOUSE_TRAFO_METHODDEF) */
+
 #ifndef _CURSES_WINDOW_NOUTREFRESH_METHODDEF
     #define _CURSES_WINDOW_NOUTREFRESH_METHODDEF
 #endif /* !defined(_CURSES_WINDOW_NOUTREFRESH_METHODDEF) */
@@ -5563,6 +5646,10 @@ _curses_has_extended_color_support(PyObject *module, 
PyObject *Py_UNUSED(ignored
     #define _CURSES_IS_TERM_RESIZED_METHODDEF
 #endif /* !defined(_CURSES_IS_TERM_RESIZED_METHODDEF) */
 
+#ifndef _CURSES_HAS_MOUSE_METHODDEF
+    #define _CURSES_HAS_MOUSE_METHODDEF
+#endif /* !defined(_CURSES_HAS_MOUSE_METHODDEF) */
+
 #ifndef _CURSES_MOUSEINTERVAL_METHODDEF
     #define _CURSES_MOUSEINTERVAL_METHODDEF
 #endif /* !defined(_CURSES_MOUSEINTERVAL_METHODDEF) */
@@ -5602,4 +5689,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=f48f8e3554b30b86 input=a9049054013a1b77]*/
+/*[clinic end generated code: output=09d21a41a5bd86dc input=a9049054013a1b77]*/

_______________________________________________
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