From 9eacedc624b26b40063fd6259fb71da3fbe948f5 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 10 Jan 2026 21:31:20 -0500 Subject: [PATCH] Input Enums instead of strings. --- src/McRFPy_API.cpp | 24 +++ src/PyInputState.cpp | 187 ++++++++++++++++++ src/PyInputState.h | 34 ++++ src/PyKey.cpp | 335 +++++++++++++++++++++++++++++++++ src/PyKey.h | 44 +++++ src/PyMouseButton.cpp | 205 ++++++++++++++++++++ src/PyMouseButton.h | 37 ++++ stubs/mcrfpy.pyi | 201 ++++++++++++++++++++ tests/unit/test_input_enums.py | 138 ++++++++++++++ 9 files changed, 1205 insertions(+) create mode 100644 src/PyInputState.cpp create mode 100644 src/PyInputState.h create mode 100644 src/PyKey.cpp create mode 100644 src/PyKey.h create mode 100644 src/PyMouseButton.cpp create mode 100644 src/PyMouseButton.h create mode 100644 tests/unit/test_input_enums.py diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index 5bf0400..e0dc9de 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -13,6 +13,9 @@ #include "PyFOV.h" #include "PyTransition.h" #include "PyEasing.h" +#include "PyKey.h" +#include "PyMouseButton.h" +#include "PyInputState.h" #include "PySound.h" #include "PyMusic.h" #include "PyKeyboard.h" @@ -536,6 +539,27 @@ PyObject* PyInit_mcrfpy() PyErr_Clear(); } + // Add Key enum class for keyboard input + PyObject* key_class = PyKey::create_enum_class(m); + if (!key_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + + // Add MouseButton enum class for mouse input + PyObject* mouse_button_class = PyMouseButton::create_enum_class(m); + if (!mouse_button_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + + // Add InputState enum class for input event states (pressed/released) + PyObject* input_state_class = PyInputState::create_enum_class(m); + if (!input_state_class) { + // If enum creation fails, continue without it (non-fatal) + PyErr_Clear(); + } + // Add automation submodule PyObject* automation_module = McRFPy_Automation::init_automation_module(); if (automation_module != NULL) { diff --git a/src/PyInputState.cpp b/src/PyInputState.cpp new file mode 100644 index 0000000..ea8fe55 --- /dev/null +++ b/src/PyInputState.cpp @@ -0,0 +1,187 @@ +#include "PyInputState.h" +#include + +// Static storage for cached enum class reference +PyObject* PyInputState::input_state_enum_class = nullptr; + +// InputState entries - maps enum name to value and legacy string +struct InputStateEntry { + const char* name; // Python enum name (UPPER_SNAKE_CASE) + int value; // Integer value + const char* legacy; // Legacy string name for backwards compatibility +}; + +static const InputStateEntry input_state_table[] = { + {"PRESSED", 0, "start"}, + {"RELEASED", 1, "end"}, +}; + +static const int NUM_INPUT_STATE_ENTRIES = sizeof(input_state_table) / sizeof(input_state_table[0]); + +const char* PyInputState::to_legacy_string(bool pressed) { + return pressed ? "start" : "end"; +} + +PyObject* PyInputState::create_enum_class(PyObject* module) { + // Build the enum definition dynamically from the table + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + + code << "class InputState(IntEnum):\n"; + code << " \"\"\"Enum representing input event states (pressed/released).\n"; + code << " \n"; + code << " Values:\n"; + code << " PRESSED: Key or button was pressed (legacy: 'start')\n"; + code << " RELEASED: Key or button was released (legacy: 'end')\n"; + code << " \n"; + code << " These enum values compare equal to their legacy string equivalents\n"; + code << " for backwards compatibility:\n"; + code << " InputState.PRESSED == 'start' # True\n"; + code << " InputState.RELEASED == 'end' # True\n"; + code << " \"\"\"\n"; + + // Add enum members + for (int i = 0; i < NUM_INPUT_STATE_ENTRIES; i++) { + code << " " << input_state_table[i].name << " = " << input_state_table[i].value << "\n"; + } + + // Add legacy names and custom methods AFTER class creation + // (IntEnum doesn't allow dict attributes during class definition) + code << "\n# Add legacy name mapping after class creation\n"; + code << "InputState._legacy_names = {\n"; + for (int i = 0; i < NUM_INPUT_STATE_ENTRIES; i++) { + code << " " << input_state_table[i].value << ": \"" << input_state_table[i].legacy << "\",\n"; + } + code << "}\n\n"; + + code << R"( +def _InputState_eq(self, other): + if isinstance(other, str): + # Check enum name match (e.g., "PRESSED") + if self.name == other: + return True + # Check legacy name match (e.g., "start") + legacy = type(self)._legacy_names.get(self.value) + if legacy and legacy == other: + return True + return False + # Fall back to int comparison for IntEnum + return int.__eq__(int(self), other) + +InputState.__eq__ = _InputState_eq +InputState.__hash__ = lambda self: hash(int(self)) +InputState.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" +InputState.__str__ = lambda self: self.name +)"; + + std::string code_str = code.str(); + + // Create globals with builtins + PyObject* globals = PyDict_New(); + if (!globals) return NULL; + + PyObject* builtins = PyEval_GetBuiltins(); + PyDict_SetItemString(globals, "__builtins__", builtins); + + PyObject* locals = PyDict_New(); + if (!locals) { + Py_DECREF(globals); + return NULL; + } + + // Execute the code to create the enum + PyObject* result = PyRun_String(code_str.c_str(), Py_file_input, globals, locals); + if (!result) { + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + Py_DECREF(result); + + // Get the InputState class from locals + PyObject* input_state_class = PyDict_GetItemString(locals, "InputState"); + if (!input_state_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create InputState enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + + Py_INCREF(input_state_class); + + // Cache the reference for fast type checking + input_state_enum_class = input_state_class; + Py_INCREF(input_state_enum_class); + + // Add to module + if (PyModule_AddObject(module, "InputState", input_state_class) < 0) { + Py_DECREF(input_state_class); + Py_DECREF(globals); + Py_DECREF(locals); + input_state_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + + return input_state_class; +} + +int PyInputState::from_arg(PyObject* arg, bool* out_pressed) { + // Accept InputState enum member + if (input_state_enum_class && PyObject_IsInstance(arg, input_state_enum_class)) { + PyObject* value = PyObject_GetAttrString(arg, "value"); + if (!value) { + return 0; + } + long val = PyLong_AsLong(value); + Py_DECREF(value); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + *out_pressed = (val == 0); // PRESSED = 0 + return 1; + } + + // Accept int + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val == 0 || val == 1) { + *out_pressed = (val == 0); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid InputState value: %ld. Must be 0 (PRESSED) or 1 (RELEASED).", val); + return 0; + } + + // Accept string (both new and legacy names) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check all entries for both name and legacy match + for (int i = 0; i < NUM_INPUT_STATE_ENTRIES; i++) { + if (strcmp(name, input_state_table[i].name) == 0 || + strcmp(name, input_state_table[i].legacy) == 0) { + *out_pressed = (input_state_table[i].value == 0); + return 1; + } + } + + PyErr_Format(PyExc_ValueError, + "Unknown InputState: '%s'. Use InputState.PRESSED, InputState.RELEASED, " + "or legacy strings 'start', 'end'.", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "InputState must be mcrfpy.InputState enum member, string, or int"); + return 0; +} diff --git a/src/PyInputState.h b/src/PyInputState.h new file mode 100644 index 0000000..971cdbd --- /dev/null +++ b/src/PyInputState.h @@ -0,0 +1,34 @@ +#pragma once +#include "Common.h" +#include "Python.h" + +// Module-level InputState enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.InputState +// +// Values: +// PRESSED = 0 (corresponds to "start" in legacy API) +// RELEASED = 1 (corresponds to "end" in legacy API) +// +// The enum compares equal to both its name ("PRESSED") and legacy string ("start") + +class PyInputState { +public: + // Create the InputState enum class and add to module + // Returns the enum class (new reference), or NULL on error + static PyObject* create_enum_class(PyObject* module); + + // Helper to extract input state from Python arg + // Accepts InputState enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + // out_pressed is set to true for PRESSED/start, false for RELEASED/end + static int from_arg(PyObject* arg, bool* out_pressed); + + // Convert bool to legacy string name (for passing to callbacks) + static const char* to_legacy_string(bool pressed); + + // Cached reference to the InputState enum class for fast type checking + static PyObject* input_state_enum_class; + + // Number of input states + static const int NUM_INPUT_STATES = 2; +}; diff --git a/src/PyKey.cpp b/src/PyKey.cpp new file mode 100644 index 0000000..8cdefb3 --- /dev/null +++ b/src/PyKey.cpp @@ -0,0 +1,335 @@ +#include "PyKey.h" +#include +#include + +// Static storage for cached enum class reference +PyObject* PyKey::key_enum_class = nullptr; + +// Key entries - maps enum name to SFML value and legacy string +struct KeyEntry { + const char* name; // Python enum name (UPPER_SNAKE_CASE) + int value; // Integer value (matches sf::Keyboard::Key) + const char* legacy; // Legacy string name for backwards compatibility +}; + +// Complete key table matching SFML's sf::Keyboard::Key enum +static const KeyEntry key_table[] = { + // Letters (sf::Keyboard::A = 0 through sf::Keyboard::Z = 25) + {"A", sf::Keyboard::A, "A"}, + {"B", sf::Keyboard::B, "B"}, + {"C", sf::Keyboard::C, "C"}, + {"D", sf::Keyboard::D, "D"}, + {"E", sf::Keyboard::E, "E"}, + {"F", sf::Keyboard::F, "F"}, + {"G", sf::Keyboard::G, "G"}, + {"H", sf::Keyboard::H, "H"}, + {"I", sf::Keyboard::I, "I"}, + {"J", sf::Keyboard::J, "J"}, + {"K", sf::Keyboard::K, "K"}, + {"L", sf::Keyboard::L, "L"}, + {"M", sf::Keyboard::M, "M"}, + {"N", sf::Keyboard::N, "N"}, + {"O", sf::Keyboard::O, "O"}, + {"P", sf::Keyboard::P, "P"}, + {"Q", sf::Keyboard::Q, "Q"}, + {"R", sf::Keyboard::R, "R"}, + {"S", sf::Keyboard::S, "S"}, + {"T", sf::Keyboard::T, "T"}, + {"U", sf::Keyboard::U, "U"}, + {"V", sf::Keyboard::V, "V"}, + {"W", sf::Keyboard::W, "W"}, + {"X", sf::Keyboard::X, "X"}, + {"Y", sf::Keyboard::Y, "Y"}, + {"Z", sf::Keyboard::Z, "Z"}, + + // Number row (sf::Keyboard::Num0 = 26 through Num9 = 35) + {"NUM_0", sf::Keyboard::Num0, "Num0"}, + {"NUM_1", sf::Keyboard::Num1, "Num1"}, + {"NUM_2", sf::Keyboard::Num2, "Num2"}, + {"NUM_3", sf::Keyboard::Num3, "Num3"}, + {"NUM_4", sf::Keyboard::Num4, "Num4"}, + {"NUM_5", sf::Keyboard::Num5, "Num5"}, + {"NUM_6", sf::Keyboard::Num6, "Num6"}, + {"NUM_7", sf::Keyboard::Num7, "Num7"}, + {"NUM_8", sf::Keyboard::Num8, "Num8"}, + {"NUM_9", sf::Keyboard::Num9, "Num9"}, + + // Control keys + {"ESCAPE", sf::Keyboard::Escape, "Escape"}, + {"LEFT_CONTROL", sf::Keyboard::LControl, "LControl"}, + {"LEFT_SHIFT", sf::Keyboard::LShift, "LShift"}, + {"LEFT_ALT", sf::Keyboard::LAlt, "LAlt"}, + {"LEFT_SYSTEM", sf::Keyboard::LSystem, "LSystem"}, + {"RIGHT_CONTROL", sf::Keyboard::RControl, "RControl"}, + {"RIGHT_SHIFT", sf::Keyboard::RShift, "RShift"}, + {"RIGHT_ALT", sf::Keyboard::RAlt, "RAlt"}, + {"RIGHT_SYSTEM", sf::Keyboard::RSystem, "RSystem"}, + {"MENU", sf::Keyboard::Menu, "Menu"}, + + // Punctuation and symbols + {"LEFT_BRACKET", sf::Keyboard::LBracket, "LBracket"}, + {"RIGHT_BRACKET", sf::Keyboard::RBracket, "RBracket"}, + {"SEMICOLON", sf::Keyboard::Semicolon, "Semicolon"}, + {"COMMA", sf::Keyboard::Comma, "Comma"}, + {"PERIOD", sf::Keyboard::Period, "Period"}, + {"APOSTROPHE", sf::Keyboard::Apostrophe, "Apostrophe"}, + {"SLASH", sf::Keyboard::Slash, "Slash"}, + {"BACKSLASH", sf::Keyboard::Backslash, "Backslash"}, + {"GRAVE", sf::Keyboard::Grave, "Grave"}, + {"EQUAL", sf::Keyboard::Equal, "Equal"}, + {"HYPHEN", sf::Keyboard::Hyphen, "Hyphen"}, + + // Whitespace and editing + {"SPACE", sf::Keyboard::Space, "Space"}, + {"ENTER", sf::Keyboard::Enter, "Enter"}, + {"BACKSPACE", sf::Keyboard::Backspace, "Backspace"}, + {"TAB", sf::Keyboard::Tab, "Tab"}, + + // Navigation + {"PAGE_UP", sf::Keyboard::PageUp, "PageUp"}, + {"PAGE_DOWN", sf::Keyboard::PageDown, "PageDown"}, + {"END", sf::Keyboard::End, "End"}, + {"HOME", sf::Keyboard::Home, "Home"}, + {"INSERT", sf::Keyboard::Insert, "Insert"}, + {"DELETE", sf::Keyboard::Delete, "Delete"}, + + // Numpad operators + {"ADD", sf::Keyboard::Add, "Add"}, + {"SUBTRACT", sf::Keyboard::Subtract, "Subtract"}, + {"MULTIPLY", sf::Keyboard::Multiply, "Multiply"}, + {"DIVIDE", sf::Keyboard::Divide, "Divide"}, + + // Arrow keys + {"LEFT", sf::Keyboard::Left, "Left"}, + {"RIGHT", sf::Keyboard::Right, "Right"}, + {"UP", sf::Keyboard::Up, "Up"}, + {"DOWN", sf::Keyboard::Down, "Down"}, + + // Numpad numbers (sf::Keyboard::Numpad0 = 75 through Numpad9 = 84) + {"NUMPAD_0", sf::Keyboard::Numpad0, "Numpad0"}, + {"NUMPAD_1", sf::Keyboard::Numpad1, "Numpad1"}, + {"NUMPAD_2", sf::Keyboard::Numpad2, "Numpad2"}, + {"NUMPAD_3", sf::Keyboard::Numpad3, "Numpad3"}, + {"NUMPAD_4", sf::Keyboard::Numpad4, "Numpad4"}, + {"NUMPAD_5", sf::Keyboard::Numpad5, "Numpad5"}, + {"NUMPAD_6", sf::Keyboard::Numpad6, "Numpad6"}, + {"NUMPAD_7", sf::Keyboard::Numpad7, "Numpad7"}, + {"NUMPAD_8", sf::Keyboard::Numpad8, "Numpad8"}, + {"NUMPAD_9", sf::Keyboard::Numpad9, "Numpad9"}, + + // Function keys (sf::Keyboard::F1 = 85 through F15 = 99) + {"F1", sf::Keyboard::F1, "F1"}, + {"F2", sf::Keyboard::F2, "F2"}, + {"F3", sf::Keyboard::F3, "F3"}, + {"F4", sf::Keyboard::F4, "F4"}, + {"F5", sf::Keyboard::F5, "F5"}, + {"F6", sf::Keyboard::F6, "F6"}, + {"F7", sf::Keyboard::F7, "F7"}, + {"F8", sf::Keyboard::F8, "F8"}, + {"F9", sf::Keyboard::F9, "F9"}, + {"F10", sf::Keyboard::F10, "F10"}, + {"F11", sf::Keyboard::F11, "F11"}, + {"F12", sf::Keyboard::F12, "F12"}, + {"F13", sf::Keyboard::F13, "F13"}, + {"F14", sf::Keyboard::F14, "F14"}, + {"F15", sf::Keyboard::F15, "F15"}, + + // Misc + {"PAUSE", sf::Keyboard::Pause, "Pause"}, + + // Unknown key (for completeness) + {"UNKNOWN", sf::Keyboard::Unknown, "Unknown"}, +}; + +static const int NUM_KEY_ENTRIES = sizeof(key_table) / sizeof(key_table[0]); + +const char* PyKey::to_legacy_string(sf::Keyboard::Key key) { + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + if (key_table[i].value == static_cast(key)) { + return key_table[i].legacy; + } + } + return "Unknown"; +} + +sf::Keyboard::Key PyKey::from_legacy_string(const char* name) { + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + if (strcmp(key_table[i].legacy, name) == 0 || + strcmp(key_table[i].name, name) == 0) { + return static_cast(key_table[i].value); + } + } + return sf::Keyboard::Unknown; +} + +PyObject* PyKey::create_enum_class(PyObject* module) { + // Build the enum definition dynamically from the table + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + + code << "class Key(IntEnum):\n"; + code << " \"\"\"Enum representing keyboard keys.\n"; + code << " \n"; + code << " Values map to SFML's sf::Keyboard::Key enum.\n"; + code << " \n"; + code << " Categories:\n"; + code << " Letters: A-Z\n"; + code << " Numbers: NUM_0 through NUM_9 (top row)\n"; + code << " Numpad: NUMPAD_0 through NUMPAD_9\n"; + code << " Function: F1 through F15\n"; + code << " Modifiers: LEFT_SHIFT, RIGHT_SHIFT, LEFT_CONTROL, etc.\n"; + code << " Navigation: LEFT, RIGHT, UP, DOWN, HOME, END, PAGE_UP, PAGE_DOWN\n"; + code << " Editing: ENTER, BACKSPACE, DELETE, INSERT, TAB, SPACE\n"; + code << " Symbols: COMMA, PERIOD, SLASH, SEMICOLON, etc.\n"; + code << " \n"; + code << " These enum values compare equal to their legacy string equivalents\n"; + code << " for backwards compatibility:\n"; + code << " Key.ESCAPE == 'Escape' # True\n"; + code << " Key.LEFT_SHIFT == 'LShift' # True\n"; + code << " \"\"\"\n"; + + // Add enum members + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + code << " " << key_table[i].name << " = " << key_table[i].value << "\n"; + } + + // Add legacy names and custom methods AFTER class creation + // (IntEnum doesn't allow dict attributes during class definition) + code << "\n# Add legacy name mapping after class creation\n"; + code << "Key._legacy_names = {\n"; + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + code << " " << key_table[i].value << ": \"" << key_table[i].legacy << "\",\n"; + } + code << "}\n\n"; + + code << R"( +def _Key_eq(self, other): + if isinstance(other, str): + # Check enum name match (e.g., "ESCAPE") + if self.name == other: + return True + # Check legacy name match (e.g., "Escape") + legacy = type(self)._legacy_names.get(self.value) + if legacy and legacy == other: + return True + return False + # Fall back to int comparison for IntEnum + return int.__eq__(int(self), other) + +Key.__eq__ = _Key_eq +Key.__hash__ = lambda self: hash(int(self)) +Key.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" +Key.__str__ = lambda self: self.name +)"; + + std::string code_str = code.str(); + + // Create globals with builtins + PyObject* globals = PyDict_New(); + if (!globals) return NULL; + + PyObject* builtins = PyEval_GetBuiltins(); + PyDict_SetItemString(globals, "__builtins__", builtins); + + PyObject* locals = PyDict_New(); + if (!locals) { + Py_DECREF(globals); + return NULL; + } + + // Execute the code to create the enum + PyObject* result = PyRun_String(code_str.c_str(), Py_file_input, globals, locals); + if (!result) { + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + Py_DECREF(result); + + // Get the Key class from locals + PyObject* key_class = PyDict_GetItemString(locals, "Key"); + if (!key_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create Key enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + + Py_INCREF(key_class); + + // Cache the reference for fast type checking + key_enum_class = key_class; + Py_INCREF(key_enum_class); + + // Add to module + if (PyModule_AddObject(module, "Key", key_class) < 0) { + Py_DECREF(key_class); + Py_DECREF(globals); + Py_DECREF(locals); + key_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + + return key_class; +} + +int PyKey::from_arg(PyObject* arg, sf::Keyboard::Key* out_key) { + // Accept Key enum member + if (key_enum_class && PyObject_IsInstance(arg, key_enum_class)) { + PyObject* value = PyObject_GetAttrString(arg, "value"); + if (!value) { + return 0; + } + long val = PyLong_AsLong(value); + Py_DECREF(value); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + *out_key = static_cast(val); + return 1; + } + + // Accept int + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= -1 && val < sf::Keyboard::KeyCount) { + *out_key = static_cast(val); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid Key value: %ld. Must be -1 (Unknown) to %d.", val, sf::Keyboard::KeyCount - 1); + return 0; + } + + // Accept string (both new and legacy names) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check all entries for both name and legacy match + for (int i = 0; i < NUM_KEY_ENTRIES; i++) { + if (strcmp(name, key_table[i].name) == 0 || + strcmp(name, key_table[i].legacy) == 0) { + *out_key = static_cast(key_table[i].value); + return 1; + } + } + + PyErr_Format(PyExc_ValueError, + "Unknown Key: '%s'. Use Key enum members (e.g., Key.ESCAPE, Key.A) " + "or legacy strings (e.g., 'Escape', 'A').", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "Key must be mcrfpy.Key enum member, string, or int"); + return 0; +} diff --git a/src/PyKey.h b/src/PyKey.h new file mode 100644 index 0000000..354b36e --- /dev/null +++ b/src/PyKey.h @@ -0,0 +1,44 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include + +// Module-level Key enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.Key +// +// Values map to sf::Keyboard::Key enum values. +// The enum compares equal to both its name ("ESCAPE") and legacy string ("Escape") +// +// Naming convention: +// - Letters: A, B, C, ... Z +// - Numbers: NUM_0, NUM_1, ... NUM_9 (top row) +// - Numpad: NUMPAD_0, NUMPAD_1, ... NUMPAD_9 +// - Function keys: F1, F2, ... F15 +// - Modifiers: LEFT_SHIFT, RIGHT_SHIFT, LEFT_CONTROL, RIGHT_CONTROL, etc. +// - Navigation: LEFT, RIGHT, UP, DOWN, HOME, END, PAGE_UP, PAGE_DOWN +// - Special: ESCAPE, ENTER, SPACE, TAB, BACKSPACE, DELETE, INSERT, PAUSE + +class PyKey { +public: + // Create the Key enum class and add to module + // Returns the enum class (new reference), or NULL on error + static PyObject* create_enum_class(PyObject* module); + + // Helper to extract key from Python arg + // Accepts Key enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + static int from_arg(PyObject* arg, sf::Keyboard::Key* out_key); + + // Convert sf::Keyboard::Key to legacy string name (for passing to callbacks) + static const char* to_legacy_string(sf::Keyboard::Key key); + + // Convert legacy string to sf::Keyboard::Key + // Returns sf::Keyboard::Unknown if not found + static sf::Keyboard::Key from_legacy_string(const char* name); + + // Cached reference to the Key enum class for fast type checking + static PyObject* key_enum_class; + + // Number of keys (matches sf::Keyboard::KeyCount) + static const int NUM_KEYS = sf::Keyboard::KeyCount; +}; diff --git a/src/PyMouseButton.cpp b/src/PyMouseButton.cpp new file mode 100644 index 0000000..ca80fb6 --- /dev/null +++ b/src/PyMouseButton.cpp @@ -0,0 +1,205 @@ +#include "PyMouseButton.h" +#include + +// Static storage for cached enum class reference +PyObject* PyMouseButton::mouse_button_enum_class = nullptr; + +// MouseButton entries - maps enum name to value and legacy string +struct MouseButtonEntry { + const char* name; // Python enum name (UPPER_SNAKE_CASE) + int value; // Integer value (matches sf::Mouse::Button) + const char* legacy; // Legacy string name for backwards compatibility +}; + +static const MouseButtonEntry mouse_button_table[] = { + {"LEFT", sf::Mouse::Left, "left"}, + {"RIGHT", sf::Mouse::Right, "right"}, + {"MIDDLE", sf::Mouse::Middle, "middle"}, + {"X1", sf::Mouse::XButton1, "x1"}, + {"X2", sf::Mouse::XButton2, "x2"}, +}; + +static const int NUM_MOUSE_BUTTON_ENTRIES = sizeof(mouse_button_table) / sizeof(mouse_button_table[0]); + +const char* PyMouseButton::to_legacy_string(sf::Mouse::Button button) { + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + if (mouse_button_table[i].value == static_cast(button)) { + return mouse_button_table[i].legacy; + } + } + return "left"; // Default fallback +} + +PyObject* PyMouseButton::create_enum_class(PyObject* module) { + // Build the enum definition dynamically from the table + std::ostringstream code; + code << "from enum import IntEnum\n\n"; + + code << "class MouseButton(IntEnum):\n"; + code << " \"\"\"Enum representing mouse buttons.\n"; + code << " \n"; + code << " Values:\n"; + code << " LEFT: Left mouse button (legacy: 'left')\n"; + code << " RIGHT: Right mouse button (legacy: 'right')\n"; + code << " MIDDLE: Middle mouse button / scroll wheel click (legacy: 'middle')\n"; + code << " X1: Extra mouse button 1 (legacy: 'x1')\n"; + code << " X2: Extra mouse button 2 (legacy: 'x2')\n"; + code << " \n"; + code << " These enum values compare equal to their legacy string equivalents\n"; + code << " for backwards compatibility:\n"; + code << " MouseButton.LEFT == 'left' # True\n"; + code << " MouseButton.RIGHT == 'right' # True\n"; + code << " \"\"\"\n"; + + // Add enum members + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + code << " " << mouse_button_table[i].name << " = " << mouse_button_table[i].value << "\n"; + } + + // Add legacy names and custom methods AFTER class creation + // (IntEnum doesn't allow dict attributes during class definition) + code << "\n# Add legacy name mapping after class creation\n"; + code << "MouseButton._legacy_names = {\n"; + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + code << " " << mouse_button_table[i].value << ": \"" << mouse_button_table[i].legacy << "\",\n"; + } + code << "}\n\n"; + + code << R"( +def _MouseButton_eq(self, other): + if isinstance(other, str): + # Check enum name match (e.g., "LEFT") + if self.name == other: + return True + # Check legacy name match (e.g., "left") + legacy = type(self)._legacy_names.get(self.value) + if legacy and legacy == other: + return True + return False + # Fall back to int comparison for IntEnum + return int.__eq__(int(self), other) + +MouseButton.__eq__ = _MouseButton_eq +MouseButton.__hash__ = lambda self: hash(int(self)) +MouseButton.__repr__ = lambda self: f"{type(self).__name__}.{self.name}" +MouseButton.__str__ = lambda self: self.name +)"; + + std::string code_str = code.str(); + + // Create globals with builtins + PyObject* globals = PyDict_New(); + if (!globals) return NULL; + + PyObject* builtins = PyEval_GetBuiltins(); + PyDict_SetItemString(globals, "__builtins__", builtins); + + PyObject* locals = PyDict_New(); + if (!locals) { + Py_DECREF(globals); + return NULL; + } + + // Execute the code to create the enum + PyObject* result = PyRun_String(code_str.c_str(), Py_file_input, globals, locals); + if (!result) { + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + Py_DECREF(result); + + // Get the MouseButton class from locals + PyObject* mouse_button_class = PyDict_GetItemString(locals, "MouseButton"); + if (!mouse_button_class) { + PyErr_SetString(PyExc_RuntimeError, "Failed to create MouseButton enum class"); + Py_DECREF(globals); + Py_DECREF(locals); + return NULL; + } + + Py_INCREF(mouse_button_class); + + // Cache the reference for fast type checking + mouse_button_enum_class = mouse_button_class; + Py_INCREF(mouse_button_enum_class); + + // Add to module + if (PyModule_AddObject(module, "MouseButton", mouse_button_class) < 0) { + Py_DECREF(mouse_button_class); + Py_DECREF(globals); + Py_DECREF(locals); + mouse_button_enum_class = nullptr; + return NULL; + } + + Py_DECREF(globals); + Py_DECREF(locals); + + return mouse_button_class; +} + +int PyMouseButton::from_arg(PyObject* arg, sf::Mouse::Button* out_button) { + // Accept MouseButton enum member + if (mouse_button_enum_class && PyObject_IsInstance(arg, mouse_button_enum_class)) { + PyObject* value = PyObject_GetAttrString(arg, "value"); + if (!value) { + return 0; + } + long val = PyLong_AsLong(value); + Py_DECREF(value); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_MOUSE_BUTTON_ENTRIES) { + *out_button = static_cast(val); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid MouseButton value: %ld. Must be 0-4.", val); + return 0; + } + + // Accept int + if (PyLong_Check(arg)) { + long val = PyLong_AsLong(arg); + if (val == -1 && PyErr_Occurred()) { + return 0; + } + if (val >= 0 && val < NUM_MOUSE_BUTTON_ENTRIES) { + *out_button = static_cast(val); + return 1; + } + PyErr_Format(PyExc_ValueError, + "Invalid MouseButton value: %ld. Must be 0 (LEFT), 1 (RIGHT), 2 (MIDDLE), " + "3 (X1), or 4 (X2).", val); + return 0; + } + + // Accept string (both new and legacy names) + if (PyUnicode_Check(arg)) { + const char* name = PyUnicode_AsUTF8(arg); + if (!name) { + return 0; + } + + // Check all entries for both name and legacy match + for (int i = 0; i < NUM_MOUSE_BUTTON_ENTRIES; i++) { + if (strcmp(name, mouse_button_table[i].name) == 0 || + strcmp(name, mouse_button_table[i].legacy) == 0) { + *out_button = static_cast(mouse_button_table[i].value); + return 1; + } + } + + PyErr_Format(PyExc_ValueError, + "Unknown MouseButton: '%s'. Use MouseButton.LEFT, MouseButton.RIGHT, " + "MouseButton.MIDDLE, MouseButton.X1, MouseButton.X2, " + "or legacy strings 'left', 'right', 'middle', 'x1', 'x2'.", name); + return 0; + } + + PyErr_SetString(PyExc_TypeError, + "MouseButton must be mcrfpy.MouseButton enum member, string, or int"); + return 0; +} diff --git a/src/PyMouseButton.h b/src/PyMouseButton.h new file mode 100644 index 0000000..743a9b8 --- /dev/null +++ b/src/PyMouseButton.h @@ -0,0 +1,37 @@ +#pragma once +#include "Common.h" +#include "Python.h" +#include + +// Module-level MouseButton enum class (created at runtime using Python's IntEnum) +// Stored as a module attribute: mcrfpy.MouseButton +// +// Values map to sf::Mouse::Button: +// LEFT = 0 (corresponds to "left" in legacy API) +// RIGHT = 1 (corresponds to "right" in legacy API) +// MIDDLE = 2 (corresponds to "middle" in legacy API) +// X1 = 3 (extra button 1) +// X2 = 4 (extra button 2) +// +// The enum compares equal to both its name ("LEFT") and legacy string ("left") + +class PyMouseButton { +public: + // Create the MouseButton enum class and add to module + // Returns the enum class (new reference), or NULL on error + static PyObject* create_enum_class(PyObject* module); + + // Helper to extract mouse button from Python arg + // Accepts MouseButton enum, string (for backwards compatibility), int, or None + // Returns 1 on success, 0 on error (with exception set) + static int from_arg(PyObject* arg, sf::Mouse::Button* out_button); + + // Convert sf::Mouse::Button to legacy string name (for passing to callbacks) + static const char* to_legacy_string(sf::Mouse::Button button); + + // Cached reference to the MouseButton enum class for fast type checking + static PyObject* mouse_button_enum_class; + + // Number of mouse buttons + static const int NUM_MOUSE_BUTTONS = 5; +}; diff --git a/stubs/mcrfpy.pyi b/stubs/mcrfpy.pyi index 2a890fa..258e848 100644 --- a/stubs/mcrfpy.pyi +++ b/stubs/mcrfpy.pyi @@ -4,11 +4,212 @@ Core game engine interface for creating roguelike games with Python. """ from typing import Any, List, Dict, Tuple, Optional, Callable, Union, overload +from enum import IntEnum # Type aliases UIElement = Union['Frame', 'Caption', 'Sprite', 'Grid', 'Line', 'Circle', 'Arc'] Transition = Union[str, None] +# Enums + +class Key(IntEnum): + """Keyboard key codes. + + These enum values compare equal to their legacy string equivalents + for backwards compatibility: + Key.ESCAPE == 'Escape' # True + Key.LEFT_SHIFT == 'LShift' # True + """ + # Letters + A = 0 + B = 1 + C = 2 + D = 3 + E = 4 + F = 5 + G = 6 + H = 7 + I = 8 + J = 9 + K = 10 + L = 11 + M = 12 + N = 13 + O = 14 + P = 15 + Q = 16 + R = 17 + S = 18 + T = 19 + U = 20 + V = 21 + W = 22 + X = 23 + Y = 24 + Z = 25 + # Number row + NUM_0 = 26 + NUM_1 = 27 + NUM_2 = 28 + NUM_3 = 29 + NUM_4 = 30 + NUM_5 = 31 + NUM_6 = 32 + NUM_7 = 33 + NUM_8 = 34 + NUM_9 = 35 + # Control keys + ESCAPE = 36 + LEFT_CONTROL = 37 + LEFT_SHIFT = 38 + LEFT_ALT = 39 + LEFT_SYSTEM = 40 + RIGHT_CONTROL = 41 + RIGHT_SHIFT = 42 + RIGHT_ALT = 43 + RIGHT_SYSTEM = 44 + MENU = 45 + # Punctuation + LEFT_BRACKET = 46 + RIGHT_BRACKET = 47 + SEMICOLON = 48 + COMMA = 49 + PERIOD = 50 + APOSTROPHE = 51 + SLASH = 52 + BACKSLASH = 53 + GRAVE = 54 + EQUAL = 55 + HYPHEN = 56 + # Whitespace/editing + SPACE = 57 + ENTER = 58 + BACKSPACE = 59 + TAB = 60 + # Navigation + PAGE_UP = 61 + PAGE_DOWN = 62 + END = 63 + HOME = 64 + INSERT = 65 + DELETE = 66 + # Numpad operators + ADD = 67 + SUBTRACT = 68 + MULTIPLY = 69 + DIVIDE = 70 + # Arrows + LEFT = 71 + RIGHT = 72 + UP = 73 + DOWN = 74 + # Numpad numbers + NUMPAD_0 = 75 + NUMPAD_1 = 76 + NUMPAD_2 = 77 + NUMPAD_3 = 78 + NUMPAD_4 = 79 + NUMPAD_5 = 80 + NUMPAD_6 = 81 + NUMPAD_7 = 82 + NUMPAD_8 = 83 + NUMPAD_9 = 84 + # Function keys + F1 = 85 + F2 = 86 + F3 = 87 + F4 = 88 + F5 = 89 + F6 = 90 + F7 = 91 + F8 = 92 + F9 = 93 + F10 = 94 + F11 = 95 + F12 = 96 + F13 = 97 + F14 = 98 + F15 = 99 + # Misc + PAUSE = 100 + UNKNOWN = -1 + +class MouseButton(IntEnum): + """Mouse button codes. + + These enum values compare equal to their legacy string equivalents + for backwards compatibility: + MouseButton.LEFT == 'left' # True + MouseButton.RIGHT == 'right' # True + """ + LEFT = 0 + RIGHT = 1 + MIDDLE = 2 + X1 = 3 + X2 = 4 + +class InputState(IntEnum): + """Input event states (pressed/released). + + These enum values compare equal to their legacy string equivalents + for backwards compatibility: + InputState.PRESSED == 'start' # True + InputState.RELEASED == 'end' # True + """ + PRESSED = 0 + RELEASED = 1 + +class Easing(IntEnum): + """Easing functions for animations.""" + LINEAR = 0 + EASE_IN = 1 + EASE_OUT = 2 + EASE_IN_OUT = 3 + EASE_IN_QUAD = 4 + EASE_OUT_QUAD = 5 + EASE_IN_OUT_QUAD = 6 + EASE_IN_CUBIC = 7 + EASE_OUT_CUBIC = 8 + EASE_IN_OUT_CUBIC = 9 + EASE_IN_QUART = 10 + EASE_OUT_QUART = 11 + EASE_IN_OUT_QUART = 12 + EASE_IN_SINE = 13 + EASE_OUT_SINE = 14 + EASE_IN_OUT_SINE = 15 + EASE_IN_EXPO = 16 + EASE_OUT_EXPO = 17 + EASE_IN_OUT_EXPO = 18 + EASE_IN_CIRC = 19 + EASE_OUT_CIRC = 20 + EASE_IN_OUT_CIRC = 21 + EASE_IN_ELASTIC = 22 + EASE_OUT_ELASTIC = 23 + EASE_IN_OUT_ELASTIC = 24 + EASE_IN_BACK = 25 + EASE_OUT_BACK = 26 + EASE_IN_OUT_BACK = 27 + EASE_IN_BOUNCE = 28 + EASE_OUT_BOUNCE = 29 + EASE_IN_OUT_BOUNCE = 30 + +class FOV(IntEnum): + """Field of view algorithms for visibility calculations.""" + BASIC = 0 + DIAMOND = 1 + SHADOW = 2 + PERMISSIVE_0 = 3 + PERMISSIVE_1 = 4 + PERMISSIVE_2 = 5 + PERMISSIVE_3 = 6 + PERMISSIVE_4 = 7 + PERMISSIVE_5 = 8 + PERMISSIVE_6 = 9 + PERMISSIVE_7 = 10 + PERMISSIVE_8 = 11 + RESTRICTIVE = 12 + SYMMETRIC_SHADOWCAST = 13 + # Classes class Color: diff --git a/tests/unit/test_input_enums.py b/tests/unit/test_input_enums.py new file mode 100644 index 0000000..d1e8334 --- /dev/null +++ b/tests/unit/test_input_enums.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Test Key, MouseButton, and InputState enum functionality. + +Tests the new input-related enums that provide type-safe alternatives to +string-based key codes, mouse buttons, and event states. +""" + +import mcrfpy +import sys + + +def test_key_enum(): + """Test Key enum members and backwards compatibility.""" + print("Testing Key enum...") + + # Test that enum exists and has expected members + assert hasattr(mcrfpy, 'Key'), "mcrfpy.Key should exist" + assert hasattr(mcrfpy.Key, 'A'), "Key.A should exist" + assert hasattr(mcrfpy.Key, 'ESCAPE'), "Key.ESCAPE should exist" + assert hasattr(mcrfpy.Key, 'LEFT_SHIFT'), "Key.LEFT_SHIFT should exist" + + # Test int values + assert int(mcrfpy.Key.A) == 0, "Key.A should be 0" + assert int(mcrfpy.Key.ESCAPE) == 36, "Key.ESCAPE should be 36" + + # Test backwards compatibility with legacy strings + assert mcrfpy.Key.A == "A", "Key.A should equal 'A'" + assert mcrfpy.Key.ESCAPE == "Escape", "Key.ESCAPE should equal 'Escape'" + assert mcrfpy.Key.LEFT_SHIFT == "LShift", "Key.LEFT_SHIFT should equal 'LShift'" + assert mcrfpy.Key.RIGHT_CONTROL == "RControl", "Key.RIGHT_CONTROL should equal 'RControl'" + assert mcrfpy.Key.NUM_1 == "Num1", "Key.NUM_1 should equal 'Num1'" + assert mcrfpy.Key.NUMPAD_5 == "Numpad5", "Key.NUMPAD_5 should equal 'Numpad5'" + assert mcrfpy.Key.F12 == "F12", "Key.F12 should equal 'F12'" + assert mcrfpy.Key.SPACE == "Space", "Key.SPACE should equal 'Space'" + + # Test that enum name also matches + assert mcrfpy.Key.ESCAPE == "ESCAPE", "Key.ESCAPE should also equal 'ESCAPE'" + + print(" All Key tests passed") + + +def test_mouse_button_enum(): + """Test MouseButton enum members and backwards compatibility.""" + print("Testing MouseButton enum...") + + # Test that enum exists and has expected members + assert hasattr(mcrfpy, 'MouseButton'), "mcrfpy.MouseButton should exist" + assert hasattr(mcrfpy.MouseButton, 'LEFT'), "MouseButton.LEFT should exist" + assert hasattr(mcrfpy.MouseButton, 'RIGHT'), "MouseButton.RIGHT should exist" + assert hasattr(mcrfpy.MouseButton, 'MIDDLE'), "MouseButton.MIDDLE should exist" + + # Test int values + assert int(mcrfpy.MouseButton.LEFT) == 0, "MouseButton.LEFT should be 0" + assert int(mcrfpy.MouseButton.RIGHT) == 1, "MouseButton.RIGHT should be 1" + assert int(mcrfpy.MouseButton.MIDDLE) == 2, "MouseButton.MIDDLE should be 2" + + # Test backwards compatibility with legacy strings + assert mcrfpy.MouseButton.LEFT == "left", "MouseButton.LEFT should equal 'left'" + assert mcrfpy.MouseButton.RIGHT == "right", "MouseButton.RIGHT should equal 'right'" + assert mcrfpy.MouseButton.MIDDLE == "middle", "MouseButton.MIDDLE should equal 'middle'" + assert mcrfpy.MouseButton.X1 == "x1", "MouseButton.X1 should equal 'x1'" + assert mcrfpy.MouseButton.X2 == "x2", "MouseButton.X2 should equal 'x2'" + + # Test that enum name also matches + assert mcrfpy.MouseButton.LEFT == "LEFT", "MouseButton.LEFT should also equal 'LEFT'" + + print(" All MouseButton tests passed") + + +def test_input_state_enum(): + """Test InputState enum members and backwards compatibility.""" + print("Testing InputState enum...") + + # Test that enum exists and has expected members + assert hasattr(mcrfpy, 'InputState'), "mcrfpy.InputState should exist" + assert hasattr(mcrfpy.InputState, 'PRESSED'), "InputState.PRESSED should exist" + assert hasattr(mcrfpy.InputState, 'RELEASED'), "InputState.RELEASED should exist" + + # Test int values + assert int(mcrfpy.InputState.PRESSED) == 0, "InputState.PRESSED should be 0" + assert int(mcrfpy.InputState.RELEASED) == 1, "InputState.RELEASED should be 1" + + # Test backwards compatibility with legacy strings + assert mcrfpy.InputState.PRESSED == "start", "InputState.PRESSED should equal 'start'" + assert mcrfpy.InputState.RELEASED == "end", "InputState.RELEASED should equal 'end'" + + # Test that enum name also matches + assert mcrfpy.InputState.PRESSED == "PRESSED", "InputState.PRESSED should also equal 'PRESSED'" + assert mcrfpy.InputState.RELEASED == "RELEASED", "InputState.RELEASED should also equal 'RELEASED'" + + print(" All InputState tests passed") + + +def test_enum_repr(): + """Test that enum repr/str work correctly.""" + print("Testing enum repr/str...") + + # Test repr + assert "Key.ESCAPE" in repr(mcrfpy.Key.ESCAPE), f"repr should contain 'Key.ESCAPE', got {repr(mcrfpy.Key.ESCAPE)}" + assert "MouseButton.LEFT" in repr(mcrfpy.MouseButton.LEFT), f"repr should contain 'MouseButton.LEFT'" + assert "InputState.PRESSED" in repr(mcrfpy.InputState.PRESSED), f"repr should contain 'InputState.PRESSED'" + + # Test str + assert str(mcrfpy.Key.ESCAPE) == "ESCAPE", f"str(Key.ESCAPE) should be 'ESCAPE', got {str(mcrfpy.Key.ESCAPE)}" + assert str(mcrfpy.MouseButton.LEFT) == "LEFT", f"str(MouseButton.LEFT) should be 'LEFT'" + assert str(mcrfpy.InputState.PRESSED) == "PRESSED", f"str(InputState.PRESSED) should be 'PRESSED'" + + print(" All repr/str tests passed") + + +def main(): + """Run all enum tests.""" + print("=" * 50) + print("Input Enum Unit Tests") + print("=" * 50) + + try: + test_key_enum() + test_mouse_button_enum() + test_input_state_enum() + test_enum_repr() + + print() + print("=" * 50) + print("All tests PASSED") + print("=" * 50) + sys.exit(0) + + except AssertionError as e: + print(f"\nTest FAILED: {e}") + sys.exit(1) + except Exception as e: + print(f"\nUnexpected error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()