diff --git a/buildconfig/stubs/pygame/display.pyi b/buildconfig/stubs/pygame/display.pyi index 874b0cd7ae..64aec91ead 100644 --- a/buildconfig/stubs/pygame/display.pyi +++ b/buildconfig/stubs/pygame/display.pyi @@ -1,8 +1,10 @@ -from typing import Dict, List, Optional, Tuple, Union, overload +from typing import Dict, List, Optional, Tuple, Union, overload, Literal from pygame.constants import FULLSCREEN from pygame.surface import Surface +from pygame._sdl2 import Window + from ._common import ( ColorValue, Coordinate, @@ -87,3 +89,12 @@ def is_fullscreen() -> bool: ... def is_vsync() -> bool: ... def get_current_refresh_rate() -> int: ... def get_desktop_refresh_rates() -> List[int]: ... +def message_box( + title: str, + message: Optional[str] = None, + message_type: Literal["info", "warn", "error"] = "info", + parent_window: Optional[Window] = None, + buttons: Sequence[str] = ("OK",), + return_button: int = 0, + escape_button: Optional[int] = None, +) -> int: ... diff --git a/docs/reST/ref/display.rst b/docs/reST/ref/display.rst index 4da4d79630..c29f63103c 100644 --- a/docs/reST/ref/display.rst +++ b/docs/reST/ref/display.rst @@ -802,4 +802,34 @@ required). .. versionadded:: 2.2.0 .. ## pygame.display.set_allow_screensaver ## +.. function:: message_box + + | :sl:`Create a native GUI message box` + | :sg:`message_box(title, message=None, message_type='info', parent_window=None, buttons=('OK',), return_button=0, escape_button=None) -> int` + + :param str title: A title string. + :param str message: A message string. If this parameter is set to ``None``, the message will be the title. + :param str message_type: Set the type of message_box, could be ``"info"``, ``"warn"`` or ``"error"``. + :param tuple buttons: An optional sequence of button name strings to show to the user. + :param int return_button: Button index to use if the return key is hit, ``0`` by default. + :param int escape_button: Button index to use if the escape key is hit, ``None`` for no button linked by default. +.. + (Uncomment this after the window API is published) + :param Window parent_window: The parent window of the message_box +.. + + :return: The index of the button that was pushed. + + This function should be called on the thread that ``set_mode()`` is called. + It will block execution of that thread until the user clicks a button or + closes the message_box. + + This function may be called at any time, even before ``pygame.init()``. + + Negative values of ``return_button`` and ``escape_button`` are allowed + just like standard Python list indexing. + + .. versionadded:: 2.4.0 + + .. ## pygame.display ## diff --git a/src_c/display.c b/src_c/display.c index 06adad2a40..b991368ec2 100644 --- a/src_c/display.c +++ b/src_c/display.c @@ -93,6 +93,10 @@ _display_state_cleanup(_DisplayState *state) } } +// prevent this code block from being linked twice +// (this code block is copied by window.c) +#ifndef BUILD_STATIC + #if !defined(__APPLE__) static char *icon_defaultname = "pygame_icon.bmp"; static int icon_colorkey = 0; @@ -176,6 +180,8 @@ pg_display_resource(char *filename) return result; } +#endif // BUILD_STATIC + /* init routines */ static PyObject * pg_display_quit(PyObject *self, PyObject *_null) @@ -2686,6 +2692,198 @@ pg_set_allow_screensaver(PyObject *self, PyObject *arg, PyObject *kwargs) Py_RETURN_NONE; } +static PyObject * +pg_message_box(PyObject *self, PyObject *arg, PyObject *kwargs) +{ + const char *title = NULL; + PyObject *message = Py_None, *parent_window = Py_None; + const char *msgbox_type = "info"; + PyObject *buttons = NULL; + PyObject *escape_button_index_obj = Py_None; + + int return_button_index = 0; + + static char *keywords[] = {"title", "message", "message_type", + "parent_window", "buttons", "return_button", + "escape_button", NULL}; + + if (!PyArg_ParseTupleAndKeywords( + arg, kwargs, "s|OsO!OiO", keywords, &title, &message, &msgbox_type, + &pgWindow_Type, &parent_window, &buttons, &return_button_index, + &escape_button_index_obj)) { + return NULL; + } + + int escape_button_index = 0; + SDL_bool escape_button_used = SDL_FALSE; + if (escape_button_index_obj != Py_None) { + escape_button_index = PyLong_AsLong(escape_button_index_obj); + if (escape_button_index == -1 && PyErr_Occurred()) + return NULL; + escape_button_used = SDL_TRUE; + } + + SDL_MessageBoxData msgbox_data; + + msgbox_data.flags = 0; + if (!strcmp(msgbox_type, "info")) { + msgbox_data.flags |= SDL_MESSAGEBOX_INFORMATION; + } + else if (!strcmp(msgbox_type, "warn")) { + msgbox_data.flags |= SDL_MESSAGEBOX_WARNING; + } + else if (!strcmp(msgbox_type, "error")) { + msgbox_data.flags |= SDL_MESSAGEBOX_ERROR; + } + else { + PyErr_Format(PyExc_ValueError, + "type should be 'info', 'warn' or 'error', " + "got '%s'", + msgbox_type); + return NULL; + } + +#if SDL_VERSION_ATLEAST(2, 0, 12) + msgbox_data.flags |= SDL_MESSAGEBOX_BUTTONS_LEFT_TO_RIGHT; +#endif + + if (parent_window == Py_None) + msgbox_data.window = NULL; + else + msgbox_data.window = ((pgWindowObject *)parent_window)->_win; + + msgbox_data.colorScheme = NULL; // use system color scheme settings + + msgbox_data.title = title; + if (PyUnicode_Check(message)) { + msgbox_data.message = PyUnicode_AsUTF8(message); + if (!msgbox_data.message) + return NULL; + } + else if (message == Py_None) { + msgbox_data.message = title; + } + else { + PyErr_Format(PyExc_TypeError, "'message' must be str, not '%s'", + message->ob_type->tp_name); + return NULL; + } + + SDL_MessageBoxButtonData *buttons_data = NULL; + + if (buttons == NULL) { + buttons_data = malloc(sizeof(SDL_MessageBoxButtonData)); + buttons_data->flags = 0; + buttons_data->buttonid = 0; + buttons_data->text = "OK"; + + msgbox_data.numbuttons = 1; + + if (-1 > return_button_index || return_button_index >= 1) { + PyErr_SetString(PyExc_IndexError, + "return_button index out of range"); + goto error; + } + buttons_data->flags |= SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT; + + if (escape_button_used) { + if (-1 > escape_button_index || escape_button_index >= 1) { + PyErr_SetString(PyExc_IndexError, + "escape_button index out of range"); + goto error; + } + buttons_data->flags |= SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT; + } + } + else { + if (!PySequence_Check(buttons) || PyUnicode_Check(buttons)) { + PyErr_Format(PyExc_TypeError, + "'buttons' should be a sequence of string, got '%s'", + buttons->ob_type->tp_name); + return NULL; + } + Py_ssize_t num_buttons = PySequence_Size(buttons); + msgbox_data.numbuttons = (int)num_buttons; + if (num_buttons < 0) { + return NULL; + } + else if (num_buttons == 0) { + return RAISE(PyExc_TypeError, + "'buttons' should contain at least 1 button"); + } + + if (return_button_index < 0) { + return_button_index = (int)num_buttons + return_button_index; + } + if (0 > return_button_index || return_button_index >= num_buttons) { + return RAISE(PyExc_IndexError, "return_button index out of range"); + } + if (escape_button_used) { + if (escape_button_index < 0) { + escape_button_index = (int)num_buttons + escape_button_index; + } + if (0 > escape_button_index || + escape_button_index >= num_buttons) { + return RAISE(PyExc_IndexError, + "escape_button index out of range"); + } + } + + buttons_data = malloc(sizeof(SDL_MessageBoxButtonData) * num_buttons); + for (Py_ssize_t i = 0; i < num_buttons; i++) { +#if SDL_VERSION_ATLEAST(2, 0, 12) + PyObject *btn_name_obj = PySequence_GetItem(buttons, i); +#else + PyObject *btn_name_obj = + PySequence_GetItem(buttons, num_buttons - i - 1); +#endif + if (!btn_name_obj) + goto error; + + if (!PyUnicode_Check(btn_name_obj)) { + PyErr_SetString(PyExc_TypeError, + "'buttons' should be a sequence of string"); + goto error; + } + + const char *btn_name = PyUnicode_AsUTF8(btn_name_obj); + if (!btn_name) + goto error; + + buttons_data[i].text = btn_name; +#if SDL_VERSION_ATLEAST(2, 0, 12) + buttons_data[i].buttonid = (int)i; +#else + buttons_data[i].buttonid = (int)(num_buttons - i - 1); +#endif + buttons_data[i].flags = 0; + if (return_button_index == buttons_data[i].buttonid) + buttons_data[i].flags |= + SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT; + if (escape_button_used && + escape_button_index == buttons_data[i].buttonid) + buttons_data[i].flags |= + SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT; + } + } + + msgbox_data.buttons = buttons_data; + + int clicked_button_id; + + if (SDL_ShowMessageBox(&msgbox_data, &clicked_button_id)) { + PyErr_SetString(pgExc_SDLError, SDL_GetError()); + goto error; + } + + free(buttons_data); + return PyLong_FromLong(clicked_button_id); + +error: + free(buttons_data); + return NULL; +} + static PyMethodDef _pg_display_methods[] = { {"init", (PyCFunction)pg_display_init, METH_NOARGS, DOC_DISPLAY_INIT}, {"quit", (PyCFunction)pg_display_quit, METH_NOARGS, DOC_DISPLAY_QUIT}, @@ -2757,7 +2955,8 @@ static PyMethodDef _pg_display_methods[] = { METH_NOARGS, DOC_DISPLAY_GETALLOWSCREENSAVER}, {"set_allow_screensaver", (PyCFunction)pg_set_allow_screensaver, METH_VARARGS | METH_KEYWORDS, DOC_DISPLAY_SETALLOWSCREENSAVER}, - + {"message_box", (PyCFunction)pg_message_box, METH_VARARGS | METH_KEYWORDS, + DOC_DISPLAY_MESSAGEBOX}, {NULL, NULL, 0, NULL}}; #ifndef PYPY_VERSION @@ -2798,6 +2997,10 @@ MODINIT_DEFINE(display) if (PyErr_Occurred()) { return NULL; } + import_pygame_window(); + if (PyErr_Occurred()) { + return NULL; + } /* type preparation */ if (PyType_Ready(&pgVidInfo_Type) < 0) { diff --git a/src_c/doc/display_doc.h b/src_c/doc/display_doc.h index 6f0ae33617..05ca640d08 100644 --- a/src_c/doc/display_doc.h +++ b/src_c/doc/display_doc.h @@ -32,3 +32,4 @@ #define DOC_DISPLAY_ISVSYNC "is_vsync() -> bool\nReturns True if vertical synchronisation for pygame.display.flip() and pygame.display.update() is enabled" #define DOC_DISPLAY_GETCURRENTREFRESHRATE "get_current_refresh_rate() -> int\nReturns the screen refresh rate or 0 if unknown" #define DOC_DISPLAY_GETDESKTOPREFRESHRATES "get_desktop_refresh_rates() -> list\nReturns the screen refresh rates for all displays (in windowed mode)." +#define DOC_DISPLAY_MESSAGEBOX "message_box(title, message=None, message_type='info', parent_window=None, buttons=('OK',), return_button=0, escape_button=None) -> int\nCreate a native GUI message box" diff --git a/src_c/static.c b/src_c/static.c index 602b52fa6e..ce0b87e811 100644 --- a/src_c/static.c +++ b/src_c/static.c @@ -73,6 +73,11 @@ import_pygame_joystick(void) { } +void +import_pygame_window(void) +{ +} + PyMODINIT_FUNC PyInit_base(void); PyMODINIT_FUNC @@ -349,6 +354,8 @@ PyInit_pygame_static() #include "simd_blitters_avx2.c" #include "simd_blitters_sse2.c" +#include "window.c" + #undef pgVidInfo_Type #undef pgVidInfo_New @@ -422,5 +429,3 @@ PyInit_pygame_static() #undef MAX #undef MIN #include "scale2x.c" - -#include "window.c" diff --git a/src_c/window.c b/src_c/window.c index 21535a8433..4788388733 100644 --- a/src_c/window.c +++ b/src_c/window.c @@ -6,9 +6,6 @@ #include "doc/sdl2_video_doc.h" -// prevent that code block copied from display.c from being linked twice -#ifndef BUILD_STATIC - #if !defined(__APPLE__) static char *icon_defaultname = "pygame_icon.bmp"; static int icon_colorkey = 0; @@ -94,8 +91,6 @@ pg_display_resource(char *filename) return result; } -#endif // BUILD_STATIC - static PyTypeObject pgWindow_Type; #define pgWindow_Check(x) \ diff --git a/test/display_test.py b/test/display_test.py index 4297c934c2..f2cb0bbb44 100644 --- a/test/display_test.py +++ b/test/display_test.py @@ -919,5 +919,100 @@ def test_x11_set_mode_crash_gh1654(self): self.assertEqual((640, 480), screen.get_size()) +class MessageBoxTest(unittest.TestCase): + def test_messagebox_args(self): + mb = pygame.display.message_box + self.assertRaises(IndexError, lambda: mb("", escape_button=1)) + self.assertRaises(IndexError, lambda: mb("", escape_button=-2)) + self.assertRaises(IndexError, lambda: mb("", return_button=1)) + self.assertRaises(IndexError, lambda: mb("", return_button=-2)) + self.assertRaises( + IndexError, + lambda: mb("", buttons=("A", "B", "C"), return_button=3), + ) + self.assertRaises( + IndexError, + lambda: mb("", buttons=("A", "B", "C"), return_button=-4), + ) + self.assertRaises( + IndexError, + lambda: mb("", buttons=("A", "B", "C"), escape_button=3), + ) + self.assertRaises( + IndexError, + lambda: mb("", buttons=("A", "B", "C"), escape_button=-4), + ) + self.assertRaises(ValueError, lambda: mb("", message_type="random_str")) + self.assertRaises(TypeError, lambda: mb("", buttons=())) + self.assertRaises(TypeError, lambda: mb("", parent_window=123456)) + + +class MessageBoxInteractiveTest(unittest.TestCase): + __tags__ = ["interactive"] + + def test_message_box_type(self): + result = pygame.display.message_box( + "Test", + "Is this an error message box?", + message_type="error", + buttons=("Yes", "No"), + ) + self.assertEqual(result, 0) + + result = pygame.display.message_box( + "Test", + "Is this an info message box?", + message_type="info", + buttons=("Yes", "No"), + ) + self.assertEqual(result, 0) + + result = pygame.display.message_box( + "Test", + "Is this a warn message box?", + message_type="warn", + buttons=("Yes", "No"), + ) + self.assertEqual(result, 0) + + def test_message_box_buttons(self): + result = pygame.display.message_box("Hit the 'OK' button") + self.assertEqual(result, 0) + + result = pygame.display.message_box( + "Hit the 'Hello' button", + buttons=("Nope", "Nope", "Nope", "Hello", "Nope", "Nope"), + ) + self.assertEqual(result, 3) + + result = pygame.display.message_box( + "Press Enter on your keyboard", + buttons=("O", "O", "O", "O", "O", "O"), + return_button=4, + ) + self.assertEqual(result, 4) + + result = pygame.display.message_box( + "Press Enter on your keyboard", + buttons=("O", "O", "O", "O", "O", "O"), + return_button=-3, + ) + self.assertEqual(result, 3) + + result = pygame.display.message_box( + "Press Esc on your keyboard", + buttons=("O", "O", "O", "O", "O", "O"), + escape_button=2, + ) + self.assertEqual(result, 2) + + result = pygame.display.message_box( + "Test", + "You saw 'Yes' on the left and 'No' on the right. Is this correct?", + buttons=("Yes", "No"), + ) + self.assertEqual(result, 0) + + if __name__ == "__main__": unittest.main()